Sidebar Component for Rails
A composable sidebar component with collapsible states and mobile responsiveness.
Usage
<%= render "components/sidebar/provider", default_open: sidebar_open? do %>
<%= render "components/sidebar", state: sidebar_state do %>
<%= render "components/sidebar/header" do %>
<%# Logo/branding %>
<% end %>
<%= render "components/sidebar/content" do %>
<%= render "components/sidebar/group", title: "Navigation" do %>
<%= render "components/sidebar/menu" do %>
<%= render "components/sidebar/menu_item" do %>
<%= render "components/sidebar/menu_button",
title: "Dashboard",
icon_name: :home,
url: root_path,
active: current_page?(root_path) %>
<% end %>
<% end %>
<% end %>
<% end %>
<%= render "components/sidebar/footer" do %>
<%# User menu %>
<% end %>
<% end %>
<%= render "components/sidebar/inset" do %>
<%= render "components/header" do %>
<%= render "components/sidebar/trigger" %>
<% end %>
<%= yield %>
<% end %>
<% end %>Examples
Menu Button
<%= render "components/sidebar/menu_button",
title: "Dashboard",
icon_name: :home,
url: root_path,
active: true %>Menu Link (Avatar Style)
<%= render "components/sidebar/menu_link",
url: profile_path,
text_icon: "A",
title: "ACME Corp",
subtitle: "Workspace" %>Menu Badge and Menu Action
Both pin to the right edge of the menu item, so they must be rendered inside menu_item, as siblings of the menu_button (or menu_link). That nesting is load-bearing: the item is the positioning context, and a menu item containing an action automatically reserves right-hand padding on its button so the icon never sits on top of the label. The demo above shows both — the 24 count on Inbox and the options control on Calendar.
<%= render "components/sidebar/menu_item" do %>
<%= render "components/sidebar/menu_button",
title: "Inbox", icon_name: :inbox, url: inbox_path %>
<%= render "components/sidebar/menu_badge", text: "24" %>
<%= render "components/sidebar/menu_action",
label: "Inbox options",
icon_name: :ellipsis,
show_on_hover: true %>
<% end %>menu_action renders a <button type="button"> by default and an <a> when you pass url:. It is icon-only, so label: is required — it becomes both the aria-label and screen-reader text. show_on_hover: true keeps the action invisible until the item is hovered or focused.
Group Action
<%= render "components/sidebar/group", title: "Projects" do %>
<%= render "components/sidebar/group_action",
label: "Add project", url: new_project_path %>
<%= render "components/sidebar/menu" do %>
<%# ... %>
<% end %>
<% end %>Separator
<%= render "components/sidebar/separator" %>Renders the separator primitive, so it keeps the primitive's 1px track while the sidebar part re-spaces it and swaps in the sidebar's own border token.
API Reference
Provider
| Parameter | Type | Default | Description |
|---|---|---|---|
| id | String | "sidebar-provider" | Element ID for stable morph matching |
| default_open | Boolean | true | Initial open state |
| variant | Symbol | :inset | Visual variant; match the sidebar's variant: |
| cookie_name | String | "sidebar_state" | Cookie for persistence |
| keyboard_shortcut | String | "b" | Toggle shortcut (Cmd/Ctrl+key) |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Sidebar
| Parameter | Type | Default | Description |
|---|---|---|---|
| id | String | "sidebar-#{side}" | Element ID; sub-elements get -gap, -overlay, -container, -inner suffixes |
| state | Symbol | :collapsed | :expanded or :collapsed |
| collapsible | Symbol | :offcanvas | :offcanvas, :icon, or :none |
| variant | Symbol | :inset | :sidebar, :floating, or :inset |
| side | Symbol | :left | :left or :right |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Menu Button
| Parameter | Type | Default | Description |
|---|---|---|---|
| title | String | required | Button text |
| url | String | required | Link URL |
| icon_name | Symbol | nil | Icon name |
| size | Symbol | :default | :default, :sm, or :lg |
| active | Boolean | false | Sets data-active="true" and aria-current="page" |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Menu Link
Renders an <a> with an optional avatar (image or text) and a title/subtitle stack. The text stack carries data-sidebar-hide-on-collapse, so it disappears when the sidebar collapses to icons.
| Parameter | Type | Default | Description |
|---|---|---|---|
| url | String | "#" | Link URL |
| title | String | nil | Primary text |
| subtitle | String | nil | Secondary text |
| icon | String | nil | Image path/URL for the avatar (rendered with image_tag) |
| text_icon | String | nil | Text for the avatar, used when icon is blank |
| icon_classes | String | "" | Classes for the avatar image or text span |
| active | Boolean | false | Sets data-active="true" and aria-current="page" |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Menu Badge
| Parameter | Type | Default | Description |
|---|---|---|---|
| text | String | nil | Badge text |
| content | String | nil | HTML content via capture, or use block |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Menu Action
| Parameter | Type | Default | Description |
|---|---|---|---|
| label | String | required | aria-label and screen-reader text |
| url | String | nil | Renders an <a> when present, otherwise <button type="button"> |
| icon_name | Symbol | :ellipsis | Icon name; pass nil for no icon |
| show_on_hover | Boolean | false | Hidden until the menu item is hovered or focused |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Group
| Parameter | Type | Default | Description |
|---|---|---|---|
| title | String | nil | Renders a group-label when present |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Group Action
| Parameter | Type | Default | Description |
|---|---|---|---|
| label | String | required | aria-label and screen-reader text |
| url | String | nil | Renders an <a> when present, otherwise <button type="button"> |
| icon_name | Symbol | :ellipsis | Icon name; pass nil for no icon |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Separator
| Parameter | Type | Default | Description |
|---|---|---|---|
| orientation | Symbol | :horizontal | Passed to the separator primitive |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Trigger
Renders a <button> with the sidebar-trigger controller and an sr-only "Toggle Sidebar" label. It can sit anywhere on the page.
| Parameter | Type | Default | Description |
|---|---|---|---|
| icon_name | Symbol | :left_panel | Icon name for toggle button |
| css_classes | String | "" | Additional CSS classes |
| html_options | Hash | {} | Additional HTML attributes |
Other Parts
These take only css_classes: and **html_options, and yield a block.
| Partial | Element | Description |
|---|---|---|
| sidebar/header | <div> |
Top section for logo/branding |
| sidebar/content | <div> |
Scrollable middle section |
| sidebar/footer | <div> |
Bottom section for user menu |
| sidebar/menu | <ul> |
List container for menu items |
| sidebar/menu_item | <li> |
Individual menu item wrapper; positioning context for badge and action |
| sidebar/inset | <main> |
Main content area wrapper |
Variants
Set variant: on both the provider and the sidebar.
| Variant | Description |
|---|---|
| :sidebar | Plain sidebar flush against the edge |
| :floating | Inner panel gets a border, radius and shadow |
| :inset | Main content (sidebar/inset) is inset inside the sidebar background (default) |
Menu Button Sizes
| Size | Description |
|---|---|
| :default | h-8 (default) |
| :sm | h-7, text-xs |
| :lg | h-12, for two-line content |
Helper Methods
| Method | Description |
|---|---|
| sidebar_state(cookie_name = "sidebar_state") | Returns :expanded or :collapsed from the cookie; :expanded when no cookie exists |
| sidebar_open?(cookie_name = "sidebar_state") | Returns true if expanded |
| sidebar_closed?(cookie_name = "sidebar_state") | Returns true if collapsed |
Stimulus Controllers
sidebar
Attached to sidebar/provider (which also carries data-outlet="sidebar").
| Value | Type | Default | Description |
|---|---|---|---|
| open | Boolean | true | Current state (the provider renders it from default_open) |
| defaultOpen | Boolean | true | Fallback when no cookie exists |
| cookieName | String | "sidebar_state" | Cookie that persists the desktop state |
| cookieMaxAge | Number | 31536000 | Cookie lifetime in seconds (one year) |
| keyboardShortcut | String | "b" | Key used with Cmd/Ctrl |
| Target | Element |
|---|---|
| sidebar | The <aside> rendered by components/sidebar |
| container | The fixed sidebar container (receives inert when off-canvas) |
| backdrop | Mobile overlay |
| Action | Description |
|---|---|
| toggle / open / close | Change the open state |
| toggleMobile | Toggle only below 768px |
| toggleWithKeyboard | Bound by the provider to keydown.meta+<key>@window and keydown.ctrl+<key>@window |
| backdropClick | Closes the sidebar on mobile; bound on the backdrop |
The controller dispatches sidebar:stateChanged (bubbling) with detail: { open, mobile, state, collapsible } whenever the state changes. It's also dispatched after every page morph, so listeners can re-sync. Below 768px the sidebar starts closed and its state is not written to the cookie.
sidebar-trigger
Attached to sidebar/trigger. It uses a sidebar outlet (data-sidebar-trigger-sidebar-outlet="[data-outlet='sidebar']") and exposes one action, triggerClick, which toggles every connected sidebar. It listens for sidebar:stateChanged to keep aria-expanded in sync, and sets aria-controls to the sidebar's id.
Accessibility
- A collapsed off-canvas sidebar is out of the tab order. When it is parked off-screen, its container carries
inert, so keyboard focus skips it entirely rather than walking through a screenful of destinations no pointer can reach. It is applied server-side as well as by the controller, so the invariant holds before Stimulus connects. Acollapsible: :iconsidebar is a visible rail and stays reachable; an open sidebar obviously does too. - Below 768px the sidebar reserves no layout. The container is a fixed overlay with a backdrop at that width, so the gap that normally holds space for it collapses to zero — structurally, in CSS, whatever the state cookie says and before any JavaScript has run. A phone load carrying an expanded cookie gets a full-width content column in the first painted frame, with no settle.
- Sidebar items expose
aria-current="page"when active, and the trigger keepsaria-expandedandaria-controlsin sync with the sidebar it drives.
Turbo Drive
The sidebar controller integrates with Turbo Drive to maintain correct state across navigations:
- Cache teardown: When you navigate to another page on mobile, the sidebar closes and the backdrop is hidden before Turbo caches the page, so pressing back never shows a stale open sidebar or a scroll-locked body.
- Page refreshes keep the sidebar open: A page refresh is a same-path
replacevisit. That includesTurbo.visit(location.href, { action: "replace" }), refresh broadcasts (broadcasts_refreshes,turbo_stream.refresh) and form redirects back to the same URL. Withturbo_refresh_method_tag :morph, the sidebar keeps its live state through it: a sidebar you opened on mobile stays open, with its backdrop, scroll lock and the trigger'saria-expanded. On desktop it re-reads the cookie, as before. - Morph-safe attributes: The morph doesn't overwrite the attributes the controller owns (
data-sidebar-open-valueon the provider;data-stateanddata-collapsibleon the sidebar;data-stateandclasson the backdrop;inerton the container;aria-expandedandaria-controlson the trigger). The menu contents still morph normally. - Breakpoint crossing: Resizing from desktop into the mobile range closes the sidebar, whatever its state. Resizing back restores the saved desktop state.
- Desktop persistence: The sidebar state is stored in a cookie, so it survives full page loads and Turbo navigations without extra configuration.
Stable IDs
The sidebar generates deterministic IDs based on its side: parameter (sidebar-left, sidebar-right) instead of random IDs. This allows idiomorph to match old and new elements across morph renders, preventing the sidebar from being destroyed and recreated.
The provider div also receives a stable ID (sidebar-provider) for the same reason.
If you render multiple sidebars on the same side, pass explicit id: parameters to avoid collisions:
<%= render "components/sidebar/provider", id: "sidebar-main" do %>
<%= render "components/sidebar", id: "sidebar-nav", side: :left do %>
...
<% end %>
<% end %>Morph Compatibility
During a Turbo morph, the server-rendered data-sidebar-open-value may carry a stale value (e.g., from a broadcast where the server has no access to the browser cookie). The controller treats the browser cookie as the source of truth:
- Before morph updates attributes, the controller sets an internal guard flag.
- When idiomorph overwrites
data-sidebar-open-value, the Stimulus value callback is skipped — preventing the stale server value from overwriting the cookie. - After morph completes, the controller reads the cookie, reasserts the correct state, and removes the
sidebar-loadingclass that morph re-adds from server HTML.
Turbo Frames
The sidebar works inside Turbo Frames because stable IDs enable clean Stimulus disconnect/reconnect cycles. On reconnection, initialize() re-reads the cookie, so the sidebar always reflects the latest client-side state.