a side panel that folds away, left or right, its toggle anywhere, kept across reloads
npx shadcn add @ag/sidebarAlready installed? Preview an update
npx shadcn add @ag/sidebar --dry-runYour 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, witharia-expanded,aria-controlsand its keys in a tooltip.remembertakes a rememberedSetting<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, seeshell. - 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.
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
"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
"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>
)
}