Streamlit-rail-nav: a collapsible icon-rail sidebar that expands on hover — no iframe, no frontend build

Hey everyone,

I’m excited to share streamlit-rail-nav: a configurable icon-rail sidebar for Streamlit apps. The sidebar collapses down to a thin strip of icons and smoothly expands into a full labeled menu on hover — the pattern you see in a lot of modern SaaS dashboards.

streamlit-rail-nav demo

Demo

Live demo →

GitHub repo

github.com/aizech/streamlit-rail-nav

PyPI

streamlit-rail-nav · PyPI

Installation

pip install streamlit-rail-nav

Quick start

import streamlit as st
from streamlit_rail_nav import render

st.set_page_config(layout="wide", initial_sidebar_state="expanded")

render({
    "items": [
        {"kind": "link", "label": "Home", "icon": "home", "page": "app.py"},
        {"kind": "badge_link", "label": "Data", "icon": "table", 
         "page": "pages/Data.py", "badge": "New"},
        {"kind": "separator"},
        {"kind": "header", "label": "Workspace"},
        {"kind": "search", "label": "Quick search", "icon": "search"},
        {"kind": "theme_toggle", "label": "Dark mode", "icon": "dark_mode"},
        {"kind": "user_profile", "name": "Ada Lovelace", "subtitle": "Pro trial"},
    ],
}, footer="2026 | My App")

That’s the whole loop — one dict, one function call. config normalizes over sensible defaults, so you only pass the keys you want to override.

What it does

  • Nine item kinds, not just links: link, badge_link, separator, spacer, header, theme_toggle, search, upgrade_card, user_profile — enough to build a real product sidebar (nav + search + a promo card + a user footer) without hand-rolling CSS.
  • Fully themeable: colors, sizes, widths, gaps, transitions are all config keys, plus eight built-in presets (THEME_PRESETS) that are pre-verified to pass WCAG AA contrast in both resting and hover states.
  • Brand block: drop in a logo (shown while expanded) and an icon (shown while collapsed), with automatic light/dark variant selection based on your sidebar background color.
  • Plain-dict config — round-trips cleanly through JSON, so you can persist a user’s sidebar customization in a database or settings file with no custom serialization.
  • Callbacks, not magic: on_theme_toggle, on_search, on_upgrade_click fire when the user interacts, and you decide what happens to session_state — the component doesn’t force reruns or own your app state.

How it works under the hood

There’s no iframe, no st.components.v1.declare_component, no JS build step. render() calls native st.sidebar.page_link, st.sidebar.button, and st.sidebar.text_input, then injects a <style> block that restyles the real stSidebar into a rail (:not(:hover) collapses it to icon width; :hover expands it with a CSS transition). Since it’s all real Streamlit widgets, st.page_link’s navigation, st.button’s click handling, and session state all keep working exactly the way you’d expect — there’s no postMessage bridge to keep in sync with your Python state.

Background

I originally built this as an internal hover-menu inside a larger Streamlit app of mine, loosely inspired by the now-archived Socvest/streamlit-on-Hover-tabs (which used an iframe-based approach). Once the CSS-injection technique proved solid and grew presets, search, an upgrade-card slot, and more, it made sense to pull it out into its own package with a generic config schema and a proper test suite.

Caveats

Since this is CSS-injection against data-testid attributes (not a stable public API), I’ve pinned streamlit>=1.57,<2 and verified against that range. Badge pills are positioned with nth-of-type on sidebar element order, so render() needs to run before any other widgets hit st.sidebar in the same run.

If you try it, I’d love to hear what breaks on your Streamlit version or what item kind you wish it had. Issues and PRs welcome on GitHub.

— Bernhard (@aizech), Corpus Analytica

1 Like