Skip to main content

Sidebar

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
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 auto Element ID
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 Whether active
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Menu Link

Parameter Type Default Description
url String required Link URL
title String required Primary text
subtitle String nil Secondary text
text_icon String nil Text for avatar
icon String nil Image URL for avatar
active Boolean false Whether active
css_classes String "" Additional CSS classes
html_options Hash {} Additional HTML attributes

Trigger

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

Partial Description
sidebar/header Top section for logo/branding
sidebar/content Scrollable middle section
sidebar/footer Bottom section for user menu
sidebar/group Groups menu items with optional title
sidebar/menu List container for menu items
sidebar/menu_item Individual menu item wrapper
sidebar/menu_badge Count or short label pinned inside a menu item. text: / content:
sidebar/menu_action Icon control pinned right inside a menu item. label: (required), url:, icon_name:, show_on_hover:
sidebar/group_action Icon control pinned to the right of a group label. label: (required), url:, icon_name:
sidebar/separator Divider between groups. orientation: (default :horizontal)
sidebar/trigger Toggle button for sidebar
sidebar/inset Main content area wrapper

Helper Methods

Method Description
sidebar_state(cookie_name) Returns :expanded or :collapsed
sidebar_open?(cookie_name) Returns true if expanded
sidebar_closed?(cookie_name) Returns true if collapsed

Turbo Drive

The sidebar controller integrates with Turbo Drive to maintain correct state across navigations:

  • Cache teardown: On mobile, the sidebar closes and the backdrop is hidden before Turbo caches the page. Pressing back never shows a stale open sidebar or scroll-locked body.
  • Morph awareness: When using turbo_refresh_method_tag :morph, the sidebar re-reads its cookie to preserve the desktop toggle state and forces closed on mobile after a morph refresh.
  • 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:

  1. Before morph updates attributes, the controller sets an internal guard flag.
  2. When idiomorph overwrites data-sidebar-open-value, the Stimulus value callback is skipped — preventing the stale server value from overwriting the cookie.
  3. After morph completes, the controller reads the cookie, reasserts the correct state, and removes the sidebar-loading class 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.