Skip to main content
Gremorie

Sidebar

Composable app-shell sidebar with persisted state, icon-only collapse, mobile drawer, and a 24-piece compound API.

Overview

Sidebar is the cornerstone navigation primitive of the registry: a composable app-shell sidebar with header / content / footer regions, persisted collapsed state (via cookie), keyboard shortcut (Cmd / Ctrl + B), icon-only collapse mode with tooltip fallbacks, automatic mobile drawer via Sheet, and 24 subcomponents that compose into nav menus, badges, actions, skeletons, and sub-menus.

Reach for Sidebar when the application has more than one screen worth of navigation - workspaces, settings sections, content categories. The primitive owns the layout chrome (the rail, the gap, the inset main area) so callers focus on the menu structure. For marketing-site nav use NavigationMenu; for in-page section switching use Tabs.

Installation

bash npx gremorie@latest add rx-sidebar
bash pnpm dlx gremorie@latest add rx-sidebar
bash yarn dlx gremorie@latest add rx-sidebar
bash bunx --bun gremorie@latest add rx-sidebar

Usage

import { HomeIcon, InboxIcon, SettingsIcon } from "lucide-react";
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarTrigger,
} from "@gremorie/rx-navigation";

export function AppShell() {
  return (
    <SidebarProvider>
      <Sidebar>
        <SidebarHeader>Workspace</SidebarHeader>
        <SidebarContent>
          <SidebarGroup>
            <SidebarGroupLabel>Navigation</SidebarGroupLabel>
            <SidebarGroupContent>
              <SidebarMenu>
                <SidebarMenuItem>
                  <SidebarMenuButton tooltip="Home">
                    <HomeIcon />
                    <span>Home</span>
                  </SidebarMenuButton>
                </SidebarMenuItem>
                <SidebarMenuItem>
                  <SidebarMenuButton tooltip="Inbox">
                    <InboxIcon />
                    <span>Inbox</span>
                  </SidebarMenuButton>
                </SidebarMenuItem>
                <SidebarMenuItem>
                  <SidebarMenuButton tooltip="Settings">
                    <SettingsIcon />
                    <span>Settings</span>
                  </SidebarMenuButton>
                </SidebarMenuItem>
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        </SidebarContent>
      </Sidebar>
      <SidebarInset>
        <header className="flex items-center gap-2 border-b p-4">
          <SidebarTrigger />
          <h1 className="font-semibold">Page title</h1>
        </header>
        <main className="p-6">...</main>
      </SidebarInset>
    </SidebarProvider>
  );
}

Angular edition planned for Phase 5h. Star the repo to track progress.

Wrap each Storybook story and each docs preview in its own <SidebarProvider>. A global provider mounted in apps/docs would conflict with the Fumadocs sidebar.

API

The Sidebar primitive exposes 24 exports plus a useSidebar() hook. The API is organised by role: state container, root frame, structural regions, group blocks, menu items, sub-menus, and ancillary controls.

State container

<SidebarProvider>

The context provider that owns the open / collapsed state, the mobile-open state, the keyboard shortcut, and cookie persistence. Every subtree that uses Sidebar must be wrapped.

PropTypeDefaultDescription
defaultOpenbooleantrueInitial open state when uncontrolled. Read from a server-side cookie for SSR persistence.
openboolean-Controlled open state.
onOpenChange(open: boolean) => void-Fires when the open state changes.
styleReact.CSSProperties-Merged onto the wrapper. Use to override --sidebar-width / --sidebar-width-icon.
classNamestring-Extra classes for the wrapper.

The provider writes sidebar_state=<true|false> to a 7-day cookie on every change, so the collapsed state survives reloads.

useSidebar()

Hook that exposes the context value. Throws if called outside a <SidebarProvider>.

const {
  state, // "expanded" | "collapsed"
  open, // boolean
  setOpen, // (open: boolean) => void
  openMobile, // boolean
  setOpenMobile, // (open: boolean) => void
  isMobile, // boolean (true below md breakpoint)
  toggleSidebar, // () => void (toggles desktop or mobile depending on viewport)
} = useSidebar();

Root frame

The sidebar shell. Picks between three rendering paths automatically: a static panel (collapsible="none"), a desktop sidebar with the rail + gap + container layout, or a mobile <Sheet>.

PropTypeDefaultDescription
side"left" | "right""left"Which edge of the viewport the sidebar docks to.
variant"sidebar" | "floating" | "inset""sidebar""sidebar" is flush to the edge with a border. "floating" is a rounded card with shadow. "inset" is a flush sidebar paired with <SidebarInset> to render the main area inside a card.
collapsible"offcanvas" | "icon" | "none""offcanvas""offcanvas" slides the entire sidebar away. "icon" collapses to icon-only width (3 rem). "none" disables collapse and always renders open.

<SidebarTrigger>

The toggle button. Wraps the Forms <Button variant="ghost" size="icon"> with a PanelLeft icon and a visually hidden "Toggle Sidebar" label. Forwards onClick so callers can chain analytics.

PropTypeDefaultDescription
...propsReact.ComponentProps<typeof Button>-Forwarded to the underlying button.

<SidebarRail>

A thin grab handle along the inner edge of the sidebar. Click to toggle. Hidden on mobile, hidden when collapsible="none", hidden in the offcanvas collapsed state.

<SidebarInset>

The <main> area paired with the sidebar. With variant="inset", renders the main as a rounded card inset from the viewport edges. Use it instead of a plain <main> when you want the visual chrome to match the floating / inset variants.

Structural regions

ComponentRole
<SidebarHeader>Top region of the sidebar. Renders flex flex-col gap-2 p-2. Place the brand mark, workspace switcher, or a search input here.
<SidebarContent>Scrollable middle region. Renders flex min-h-0 flex-1 flex-col gap-2 overflow-auto. Hosts one or more <SidebarGroup> blocks.
<SidebarFooter>Bottom region. Renders flex flex-col gap-2 p-2. Place the user menu, theme toggle, or quick actions here.
<SidebarSeparator>A <Separator> styled with mx-2 w-auto bg-sidebar-border for use between regions.
<SidebarInput>The Forms <Input> styled for the sidebar density (h-8, transparent shadow).

Group blocks

ComponentRole
<SidebarGroup>A logical block inside <SidebarContent>. Renders relative flex w-full min-w-0 flex-col p-2.
<SidebarGroupLabel>A small uppercase label at the top of a group. Hidden automatically in collapsible="icon" collapsed state. Supports asChild.
<SidebarGroupAction>An action button absolutely positioned in the top-right of a group label (e.g. a "+" to add a new project). Hidden in collapsible="icon" collapsed state. Supports asChild.
<SidebarGroupContent>The body of the group. Hosts a <SidebarMenu>.

<SidebarMenu> and <SidebarMenuItem>

<SidebarMenu> is a <ul> of items. <SidebarMenuItem> is the <li> wrapper that owns hover and focus state via the group/menu-item class.

<SidebarMenuButton>

The clickable menu entry. Wraps a <button> (or any element via asChild).

PropTypeDefaultDescription
asChildbooleanfalseWhen true, delegates rendering to the single child (use with framework Link).
isActivebooleanfalseMarks the current page; styles via data-active="true".
variant"default" | "outline""default""outline" adds a 1 px ring (background-tinted to read against complex backdrops).
size"default" | "sm" | "lg""default""default" is 32 px tall, "sm" 28 px, "lg" 48 px (compact, dense, and brand row respectively).
tooltipstring | TooltipContentProps-When set and the sidebar is in icon-only collapsed state, the button is wrapped in a <Tooltip> showing this content on hover. Required for icon-only menus to keep labels reachable.

<SidebarMenuAction>

A small button absolutely positioned in the top-right of a menu item (e.g. an ellipsis to open a context menu). Hidden in icon-only collapsed state.

PropTypeDefaultDescription
asChildbooleanfalseDelegate to a single child.
showOnHoverbooleanfalseWhen true, only visible on hover, focus, or when active.

<SidebarMenuBadge>

A small numeric or status badge absolutely positioned in the menu row. Hidden in icon-only collapsed state.

PropTypeDefaultDescription
...propsReact.ComponentProps<"div">-Standard div attrs.

<SidebarMenuSkeleton>

A placeholder row matching the <SidebarMenuButton> height. Use during initial data loads. The width is randomised between 50 percent and 90 percent so a stack of skeletons looks natural.

PropTypeDefaultDescription
showIconbooleanfalseWhen true, renders a square skeleton in the icon slot too.
ComponentRole
<SidebarMenuSub>A <ul> of sub-items. Hidden in icon-only collapsed state.
<SidebarMenuSubItem>A <li> wrapper for sub-items.
<SidebarMenuSubButton>A 28 px row styled for nesting; renders an <a> by default.

SidebarMenuSubButton props:

PropTypeDefaultDescription
asChildbooleanfalseDelegate to a single child (use for framework Link).
isActivebooleanfalseMarks the current location.
size"sm" | "md""md""sm" is 12 px text; "md" is 14 px text.

Composition

A typical Sidebar tree looks like this:

<SidebarProvider>
  <Sidebar>
    <SidebarHeader>{/* Brand or workspace switcher */}</SidebarHeader>

    <SidebarContent>
      <SidebarGroup>
        <SidebarGroupLabel>Workspace</SidebarGroupLabel>
        <SidebarGroupContent>
          <SidebarMenu>
            <SidebarMenuItem>
              <SidebarMenuButton tooltip="Home">
                <HomeIcon />
                <span>Home</span>
              </SidebarMenuButton>
            </SidebarMenuItem>
            <SidebarMenuItem>
              <SidebarMenuButton tooltip="Inbox">
                <InboxIcon />
                <span>Inbox</span>
              </SidebarMenuButton>
              <SidebarMenuBadge>12</SidebarMenuBadge>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarGroupContent>
      </SidebarGroup>

      <SidebarSeparator />

      <SidebarGroup>
        <SidebarGroupLabel>Projects</SidebarGroupLabel>
        <SidebarGroupAction>
          <PlusIcon />
        </SidebarGroupAction>
        <SidebarGroupContent>
          <SidebarMenu>
            {projects.map((p) => (
              <SidebarMenuItem key={p.id}>
                <SidebarMenuButton tooltip={p.name}>{p.name}</SidebarMenuButton>
                <SidebarMenuSub>
                  {p.sections.map((s) => (
                    <SidebarMenuSubItem key={s.id}>
                      <SidebarMenuSubButton href={s.href}>
                        {s.name}
                      </SidebarMenuSubButton>
                    </SidebarMenuSubItem>
                  ))}
                </SidebarMenuSub>
              </SidebarMenuItem>
            ))}
          </SidebarMenu>
        </SidebarGroupContent>
      </SidebarGroup>
    </SidebarContent>

    <SidebarFooter>{/* User menu, theme toggle */}</SidebarFooter>

    <SidebarRail />
  </Sidebar>

  <SidebarInset>
    <header>
      <SidebarTrigger />
      ...
    </header>
    <main>...</main>
  </SidebarInset>
</SidebarProvider>

The provider owns the state. The root frame owns the layout chrome. The regions, groups, and menu pieces are the consumer's composition surface.

Variations

Basic sidebar

<SidebarProvider>
  <Sidebar>
    <SidebarHeader>
      <span className="font-semibold">My App</span>
    </SidebarHeader>
    <SidebarContent>
      <SidebarMenu>
        <SidebarMenuItem>
          <SidebarMenuButton>
            <HomeIcon />
            <span>Home</span>
          </SidebarMenuButton>
        </SidebarMenuItem>
        <SidebarMenuItem>
          <SidebarMenuButton>
            <InboxIcon />
            <span>Inbox</span>
          </SidebarMenuButton>
        </SidebarMenuItem>
      </SidebarMenu>
    </SidebarContent>
  </Sidebar>
  <SidebarInset>
    <SidebarTrigger />
    <main>...</main>
  </SidebarInset>
</SidebarProvider>

Use as the minimum viable sidebar: header brand, menu of top-level items, inset main.

With groups, badges, and actions

<SidebarContent>
  <SidebarGroup>
    <SidebarGroupLabel>Mail</SidebarGroupLabel>
    <SidebarGroupContent>
      <SidebarMenu>
        <SidebarMenuItem>
          <SidebarMenuButton isActive>
            <InboxIcon />
            <span>Inbox</span>
          </SidebarMenuButton>
          <SidebarMenuBadge>12</SidebarMenuBadge>
        </SidebarMenuItem>
        <SidebarMenuItem>
          <SidebarMenuButton>
            <ArchiveIcon />
            <span>Archive</span>
          </SidebarMenuButton>
        </SidebarMenuItem>
      </SidebarMenu>
    </SidebarGroupContent>
  </SidebarGroup>

  <SidebarGroup>
    <SidebarGroupLabel>Labels</SidebarGroupLabel>
    <SidebarGroupAction aria-label="Add label">
      <PlusIcon />
    </SidebarGroupAction>
    <SidebarGroupContent>
      <SidebarMenu>
        {labels.map((label) => (
          <SidebarMenuItem key={label.id}>
            <SidebarMenuButton>{label.name}</SidebarMenuButton>
            <SidebarMenuAction showOnHover>
              <MoreHorizontalIcon />
            </SidebarMenuAction>
          </SidebarMenuItem>
        ))}
      </SidebarMenu>
    </SidebarGroupContent>
  </SidebarGroup>
</SidebarContent>

Use when the sidebar carries categorised navigation with per-group affordances (add, count) and per-row actions.

Collapsible icon mode

<SidebarProvider>
  <Sidebar collapsible="icon">
    <SidebarContent>
      <SidebarMenu>
        <SidebarMenuItem>
          <SidebarMenuButton tooltip="Home">
            <HomeIcon />
            <span>Home</span>
          </SidebarMenuButton>
        </SidebarMenuItem>
        <SidebarMenuItem>
          <SidebarMenuButton tooltip="Settings">
            <SettingsIcon />
            <span>Settings</span>
          </SidebarMenuButton>
        </SidebarMenuItem>
      </SidebarMenu>
    </SidebarContent>
  </Sidebar>
</SidebarProvider>

Use when the sidebar should collapse to icon-only width (3 rem) instead of sliding off-canvas. The tooltip prop on <SidebarMenuButton> is required in this mode - it surfaces the label in a side-aligned tooltip when collapsed.

With sub-menus

<SidebarMenuItem>
  <SidebarMenuButton>
    <FolderIcon />
    <span>Projects</span>
  </SidebarMenuButton>
  <SidebarMenuSub>
    <SidebarMenuSubItem>
      <SidebarMenuSubButton href="/projects/alpha">Alpha</SidebarMenuSubButton>
    </SidebarMenuSubItem>
    <SidebarMenuSubItem>
      <SidebarMenuSubButton href="/projects/beta" isActive>
        Beta
      </SidebarMenuSubButton>
    </SidebarMenuSubItem>
    <SidebarMenuSubItem>
      <SidebarMenuSubButton href="/projects/gamma">Gamma</SidebarMenuSubButton>
    </SidebarMenuSubItem>
  </SidebarMenuSub>
</SidebarMenuItem>

Use for two-level navigation - a project list with sections, a settings group with sub-pages. Sub-menus auto-hide in icon-only collapsed mode.

function AppShell({ defaultOpen }: { defaultOpen: boolean }) {
  const [open, setOpen] = useState(defaultOpen);

  useEffect(() => {
    // sync to cookie / server preference if desired
  }, [open]);

  return (
    <SidebarProvider open={open} onOpenChange={setOpen}>
      <Sidebar>...</Sidebar>
      <SidebarInset>...</SidebarInset>
    </SidebarProvider>
  );
}

// Server: read the sidebar_state cookie and pass as defaultOpen.

Use when the rest of the app needs to observe or override the sidebar state (analytics, multi-pane layouts).

Mobile-aware controls

function MyHeader() {
  const { isMobile, toggleSidebar, state } = useSidebar();

  return (
    <header className="flex items-center gap-2">
      <SidebarTrigger />
      {!isMobile && <span>State: {state}</span>}
    </header>
  );
}

Use to branch UI on viewport. Below md (768 px) isMobile is true and toggleSidebar opens / closes the mobile <Sheet> instead of the desktop panel.

Accessibility

  • Provider contract: useSidebar() throws when called outside <SidebarProvider>, surfacing the wiring requirement at the first render.
  • Trigger labelling: <SidebarTrigger> carries a visually hidden "Toggle Sidebar" label. The rail (<SidebarRail>) carries aria-label="Toggle Sidebar" and title="Toggle Sidebar"; it is removed from the tab order (tabIndex={-1}) because the trigger button is the keyboard-discoverable entry point.
  • Keyboard shortcut: Cmd + B (Mac) or Ctrl + B (Windows / Linux) toggles the sidebar globally. The shortcut is added to window while a provider is mounted and removed on unmount. Document the shortcut in your app's help surface.
  • Mobile drawer: below 768 px the sidebar renders inside a <Sheet> with a <SheetTitle>Sidebar</SheetTitle> and <SheetDescription> (both sr-only) so screen reader users get a proper dialog announcement.
  • Icon-only mode tooltips: when collapsible="icon" is collapsed, <SidebarMenuButton> wraps its content in a <Tooltip> aligned to the opposite side (side="right" for a left-docked sidebar). Provide tooltip="..." on every menu button in icon mode - otherwise users see icons without labels.
  • Active state: isActive on <SidebarMenuButton> and <SidebarMenuSubButton> sets data-active="true" for styling; pair with aria-current="page" (via the host link element when using asChild).
  • Cookie persistence: the sidebar_state cookie has path=/ and max-age=7 days. The cookie is not security-sensitive, but document it in your privacy disclosure if you list cookies.
  • Sheet - the dialog primitive Sidebar mounts on mobile.
  • NavigationMenu - marketing-site primary nav.
  • Tabs - in-page section switching, not app-shell navigation.
  • Tooltip - surfaces labels in icon-only collapsed mode.

On this page