← ui

sidebar

ui

a side panel that folds away, left or right, its toggle anywhere, kept across reloads

npx shadcn add @ag/sidebar
Already installed? Preview an update
npx shadcn add @ag/sidebar --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-30
  • New: a side panel beside content that folds away, in a page, a panel or a dialog. useSidebar({ side, label, open | defaultOpen, onOpenChange, remember, keys }) holds its state; <Sidebar sidebar={…} width> draws it, filling the height it is given and scrolling what it holds; <SidebarToggle sidebar={…}> opens and closes it from anywhere, with aria-expanded, aria-controls and its keys in a tooltip. remember takes a remembered Setting<boolean>, kept across reloads. Closed, it takes no room and is inert, its scroll and state kept; it opens and closes at once. ⌘B a left one, ⌥⌘B a right one (Control elsewhere), on key-router. Plain or glass. For a whole page's frame, see shell.
  • Fixed: a closed sidebar leaves the flow (absolute, at its open width) instead of standing in it at zero width, where a wrapping row could fold it into another line.
Updated 2026-10-30 · release notes

also called drawer rail, collapsible panel

shelf controls

uses button, keys, key-router, material, remembered, scroll-area, tooltip

used by shell

the demo above, verbatim

sidebar.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Shell, ShellBar, ShellMain } from "@/registry/base-nova/ui/shell"
import { Sidebar, useSidebar } from "@/registry/base-nova/ui/sidebar"

// #region data
const FOLDERS = [
  "inbox",
  "drafts",
  "sent",
  "archive",
  "receipts",
  "travel",
  "the house",
  "letters",
  "old projects",
  "newsletters",
  "spam",
  "trash",
]
// #endregion

// #region mailbox
/** Folders on the left, details on the right, their toggles at the bar's two ends,
 *  where opening or closing either moves nothing. */
function Mailbox({ height }: { height: string }) {
  const folders = useSidebar({ side: "left", label: "folders" })
  const details = useSidebar({ side: "right", label: "details" })
  const [folder, setFolder] = React.useState("inbox")
  return (
    <Shell height={height} className="rounded-xl ring-1 ring-foreground/10">
      <ShellBar
        left={folders}
        right={details}
        start={<span className="truncate font-medium text-sm">{folder}</span>}
      />
      <Sidebar sidebar={folders} width="11rem">
        {FOLDERS.map((f) => (
          <Button
            key={f}
            size="sm"
            variant={f === folder ? "secondary" : "ghost"}
            className="justify-start"
            onClick={() => setFolder(f)}
          >
            {f}
          </Button>
        ))}
      </Sidebar>
      <ShellMain className="p-4 text-current/70 text-sm">
        The ferry runs again from Monday. Tickets bought for the cancelled
        crossings are good on any sailing this month.
      </ShellMain>
      <Sidebar
        sidebar={details}
        width="12rem"
        bodyClassName="gap-2 p-4 text-sm"
      >
        <span className="text-current/50">from</span>
        <span>the harbour office</span>
        <span className="text-current/50">received</span>
        <span>this morning</span>
      </Sidebar>
    </Shell>
  )
}
// #endregion

export default function SidebarDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="plain"
        glance="wide"
        title="at the bar's ends"
        description="⌘B folds the folders, ⌥⌘B the details. The toggles sit at the ends of a shell's bar, where nothing they open can move them."
        with="data mailbox"
      >
        <Mailbox height="13.5rem" />
      </Sample>
      <Sample name="glass" title="in glass" with="data mailbox">
        <MaterialProvider material="glass">
          <div className="rounded-2xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
            <Mailbox height="20rem" />
          </div>
        </MaterialProvider>
      </Sample>
    </div>
  )
}

the source, every file an install writes

sidebar.tsx
"use client"

import { PanelLeft, PanelRight } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  ariaKeyshortcuts,
  platformOf,
  pressed,
} from "@/registry/base-nova/lib/keys"
import {
  isBoolean,
  type Setting,
  setting,
} from "@/registry/base-nova/lib/remembered-core"
import { Button, type ButtonProps } from "@/registry/base-nova/ui/button"
import { useKeys } from "@/registry/base-nova/ui/key-router"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassFront, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { useRemembered } from "@/registry/base-nova/ui/remembered"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import { Tip } from "@/registry/base-nova/ui/tooltip"

// A SIDE PANEL that folds away: beside a page's content, inside a panel, in a dialog.
// Its state is a value the consumer holds (`useSidebar`), handed to the panel and to
// its toggle, and both agree from the first render, on the server too. THE TOGGLE
// NEVER MOVES when its sidebar opens or closes: it goes where nothing the sidebar
// pushes can move it, a shell's bar end, never in the content beside the sidebar.
//
//   const nav = useSidebar({ side: "left", label: "projects", remember: NAV })
//   <Shell>
//     <ShellBar left={nav} start={title} />      the toggle, at the bar's start
//     <Sidebar sidebar={nav} width="14rem">…</Sidebar>
//     <ShellMain>…</ShellMain>
//   </Shell>
//
// It fills the height its container gives it and scrolls what it holds itself
// (`ScrollArea`). Closed, it takes no room and is out of reach (inert), mounted, so
// its scroll and what it holds keep their state; it opens and closes at once, the
// content beside it taking the room (no transition: that content would rewrap on every
// frame of one). Open state held by the hook, outside it (`open`, `onOpenChange`), or
// kept across reloads (`remember`, a remembered setting). Keys: ⌘B a left one, ⌥⌘B a
// right one (Control elsewhere), on key-router, answered by the sidebar under the
// focus or the pointer first; `keys` names others or none.
//
// Plain, the ground it stands on with a hairline on its inner edge; glass, the
// material's front surface, blurring what is behind it, its lit edge facing the
// content. The frame of a whole page, a bar with a sidebar's toggle at each end, a
// foot and a layer over the middle, is `shell`, which places sidebars in it.

export type SidebarSide = "left" | "right"

export interface SidebarOptions {
  /** Which side of the content it stands on: its toggle's glyph, its inner edge,
   *  its default keys. Default "left". */
  side?: SidebarSide
  /** Its name: the panel's accessible name and its toggle's. Default "sidebar". */
  label?: string
  /** Open, held outside (with `onOpenChange`). */
  open?: boolean
  /** Open at first, held by the hook. Default true. */
  defaultOpen?: boolean
  /** Told every change, held outside or not. */
  onOpenChange?: (open: boolean) => void
  /** Kept across reloads under this setting (remembered), whose `initial` is the
   *  default; neither `open` nor `defaultOpen` with it. Before the page hydrates it
   *  is its default. */
  remember?: Setting<boolean>
  /** The keys that toggle it (lib/keys' notation), or `false` for none. Default
   *  "mod+B" on the left, "mod+alt+B" on the right. */
  keys?: string | false
}

/** A sidebar's state: what the panel and its toggle both read. */
export interface SidebarState {
  side: SidebarSide
  open: boolean
  setOpen: (open: boolean) => void
  toggle: () => void
  label: string
  /** The panel's element id: its toggle's `aria-controls`. */
  id: string
  keys: string | null
}

const DEFAULT_KEYS: Record<SidebarSide, string> = {
  left: "mod+B",
  right: "mod+alt+B",
}

/** What `useRemembered` is handed for a sidebar that keeps nothing: hooks are called
 *  every render, and this one is read once and never written. */
const KEPT_NOWHERE = setting("sidebar:kept-nowhere", {
  initial: true,
  keep: "tab",
  parse: isBoolean,
})

/** A sidebar's state, for the `Sidebar` and its `SidebarToggle`. */
export function useSidebar(options: SidebarOptions = {}): SidebarState {
  const {
    side = "left",
    label = "sidebar",
    open: held,
    defaultOpen,
    onOpenChange,
    remember,
    keys,
  } = options
  if (remember && (held !== undefined || defaultOpen !== undefined))
    throw new Error(
      "useSidebar: `remember` holds its state, its setting's `initial` the default; give neither `open` nor `defaultOpen` with it",
    )
  const [kept, keep] = useRemembered(remember ?? KEPT_NOWHERE)
  const [own, setOwn] = React.useState(defaultOpen ?? true)
  const id = React.useId()
  const open = held ?? (remember ? kept : own)
  const setOpen = (next: boolean) => {
    if (held === undefined) {
      if (remember) keep(next)
      else setOwn(next)
    }
    onOpenChange?.(next)
  }
  return {
    side,
    open,
    setOpen,
    toggle: () => setOpen(!open),
    label,
    id: `sidebar${id}`,
    keys: keys === false ? null : (keys ?? DEFAULT_KEYS[side]),
  }
}

/** Where a sidebar stands, said by what places it (a shell): classes for its root
 *  (sticky between a bar and a foot, its order in a row) and whether what it stands
 *  in is out of reach now (a layer over it). */
export interface SidebarPlace {
  className?: string
  inert?: boolean
}
const Place = React.createContext<SidebarPlace>({})

/** Places every sidebar inside it: a frame that lays sidebars out (shell). */
export function SidebarPlaced({
  place,
  children,
}: {
  place: SidebarPlace
  children: React.ReactNode
}) {
  return <Place.Provider value={place}>{children}</Place.Provider>
}

export type SidebarProps = Omit<React.ComponentProps<"aside">, "style"> & {
  sidebar: SidebarState
  /** Its width open, a CSS length. Default 16rem. */
  width?: string
  /** Classes on what lays out what it holds (its padding, its gap). */
  bodyClassName?: string
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  style?: React.CSSProperties
}

export function Sidebar({
  sidebar,
  width = "16rem",
  bodyClassName,
  material,
  scheme,
  className,
  style,
  children,
  ref,
  ...props
}: SidebarProps) {
  const place = React.useContext(Place)
  const m = useMaterial(material)
  const glass = m === "glass"
  const [s, here] = useSurfaceScheme<HTMLElement>(scheme, ref)
  const root = React.useRef<HTMLElement | null>(null)
  const setRoot = React.useCallback(
    (node: HTMLElement | null) => {
      root.current = node
      here(node)
    },
    [here],
  )
  // Its keys answer from what it stands in, which stays in reach while it is closed
  // (a closed sidebar is inert, and key-router offers nothing to an inert root).
  useKeys(
    (event) => {
      if (!sidebar.keys) return false
      if (!pressed(sidebar.keys, event, platformOf(navigator.platform)))
        return false
      sidebar.toggle()
      return true
    },
    {
      root: () => root.current?.parentElement ?? null,
      enabled: sidebar.keys !== null,
    },
  )
  const left = sidebar.side === "left"
  return (
    <aside
      ref={setRoot}
      id={sidebar.id}
      data-slot="sidebar"
      data-side={sidebar.side}
      data-open={sidebar.open ? "" : undefined}
      data-material={m}
      aria-label={sidebar.label}
      inert={!sidebar.open || place.inert}
      {...props}
      className={cn(
        "relative w-(--sidebar-width) shrink-0 overflow-clip text-foreground",
        schemeOf(s),
        glass
          ? [
              glassFront.end,
              // The front's lit edge faces the content: on a left one, its right.
              left && "after:right-0 after:left-auto",
            ]
          : ["border-border", sidebar.open && (left ? "border-r" : "border-l")],
        place.className,
        className,
        // Closed, it leaves the flow entirely: no room, and no zero-width item for
        // a wrapping row to fold into another line (in a shell, the bar's line,
        // which then stretched the bar to the sidebar's height). Last, so it wins
        // over a placement's `sticky`.
        !sidebar.open && "invisible absolute",
      )}
      style={{ "--sidebar-width": width, ...style } as React.CSSProperties}
    >
      <div className="relative flex h-full w-(--sidebar-width) flex-col">
        <ScrollArea
          thumb
          data-slot="sidebar-scroller"
          className={cn(
            "flex flex-1 flex-col gap-1 p-2",
            glass && "[--glass-body:1]",
            bodyClassName,
          )}
        >
          {children}
        </ScrollArea>
      </div>
    </aside>
  )
}

export type SidebarToggleProps = Omit<ButtonProps, "children" | "onClick"> & {
  sidebar: SidebarState
}

/** The button that opens and closes a sidebar: a glyph of the side it stands on, its
 *  name and keys in a tooltip, `aria-expanded` and `aria-controls`. It never moves
 *  when its sidebar opens or closes: put it where nothing the sidebar pushes can move
 *  it, a shell's bar end (`ShellBar left right` puts it there itself), never in the
 *  content beside the sidebar. */
export function SidebarToggle({
  sidebar,
  variant = "ghost",
  size = "icon",
  icon,
  ...props
}: SidebarToggleProps) {
  return (
    <Tip content={sidebar.label} keys={sidebar.keys ?? undefined}>
      <Button
        data-slot="sidebar-toggle"
        data-side={sidebar.side}
        variant={variant}
        size={size}
        aria-label={sidebar.label}
        aria-expanded={sidebar.open}
        aria-controls={sidebar.id}
        aria-keyshortcuts={
          sidebar.keys ? ariaKeyshortcuts([sidebar.keys]) : undefined
        }
        icon={
          icon ?? (sidebar.side === "left" ? <PanelLeft /> : <PanelRight />)
        }
        {...props}
        onClick={sidebar.toggle}
      />
    </Tip>
  )
}