Skip to content

Sidebar

Full-height navigation sidebar with tri-state visibility (open/rail/hidden), five active-marker variants, collapsible sections, optional intent tones, mobile sheet, and a configurable global hotkey. Composable parts plus two context providers. The mobile sheet has its own open state that always starts closed and never writes the desktop tri-state.

Installation

$pnpm exec dgadd add ui/sidebar
[Installs to]src/components/ui/sidebar[Item]ui/sidebar

dgadd is not public on npm yet. Until the first release, pack @diffgazer/add from the repository and install that tarball into this app, which is what puts dgadd on pnpm exec.

UI components require Tailwind CSS v4. Local copy mode imports src/styles/styles.css; package mode uses @diffgazer/ui CSS once packages are available.

Usage

tsx
import {  Sidebar,  SidebarContent,  SidebarFooter,  SidebarHeader,  SidebarItem,  SidebarItemBadge,  SidebarItemLabel,  SidebarSection,  SidebarSectionTitle,} from "@/components/ui/sidebar";export default function SidebarDefault() {  return (    <Sidebar embedded className="h-full">      <SidebarHeader>        <span className="text-sm font-mono font-bold">Project Explorer</span>      </SidebarHeader>      <SidebarContent>        <SidebarSection>          <SidebarSectionTitle>Components</SidebarSectionTitle>          <SidebarItem active href="#button">            Button          </SidebarItem>          <SidebarItem href="#dialog">Dialog</SidebarItem>          <SidebarItem href="#sidebar">            <SidebarItemLabel>Sidebar</SidebarItemLabel>            <SidebarItemBadge>              <span className="text-xs text-muted-foreground">new</span>            </SidebarItemBadge>          </SidebarItem>          <SidebarItem disabled>Tooltip</SidebarItem>        </SidebarSection>        <SidebarSection>          <SidebarSectionTitle>Hooks</SidebarSectionTitle>          <SidebarItem href="#use-theme">useTheme</SidebarItem>          <SidebarItem href="#use-media-query">useMediaQuery</SidebarItem>        </SidebarSection>      </SidebarContent>      <SidebarFooter>        <span className="text-xs text-muted-foreground font-mono">v1.0.0</span>      </SidebarFooter>    </Sidebar>  );}

Mobile

Below the breakpoint the sidebar renders as a Dialog sheet that owns its own open state. That state always starts closed — on a fresh mobile mount and on every breakpoint crossing — and opens only from SidebarTrigger, the Cmd/Ctrl+B hotkey, or useSidebar().onMobileOpenChange. No mobile transition writes state or fires onStateChange, so state/defaultState describe the desktop presentation only: a controlled parent pinning "rail" still gets a working sheet, a desktop → mobile → desktop round trip comes back to exactly the state it left, and persistence layers can mirror onStateChange unconditionally without a phone visit overwriting the stored desktop preference. Sidebars rendered with embedded keep their inline navigation.

Variants

Five active-marker variants — caret, inverted, bar, terminal, and tree — share one rule: structure is permanent, markers appear on the active row. caret and terminal reserve a chevron marker slot that stays invisible at rest, previews dim on hover, and lights to foreground on the active item — terminal adds the TUI rail, a continuous 1px hairline down the rows with no background fill. tree renders bold section headers with stroke-chevron folds and draws its connectors — trunk, tick, corner — as a single CSS hairline; the active item's tick gains a soft background fill. Switch between them below to compare.

Examples

Variant — terminal

Variant — tree

Collapsible sections

Rail mode

Mobile sheet

Iframe viewport ownership

Preview

Auto-tone intent dots

Render-prop items

API Reference

NameTypeDefaultDescription
variant"caret" | "inverted" | "bar" | "terminal" | "tree""caret"Visual variant. "caret" reserves a chevron marker slot shown on the active row; "inverted" full-bleeds the active row with bg-foreground; "bar" draws a 2px left edge with a soft fill on active; "terminal" shows the chevron prompt on the active item and draws a 1px hairline left rail with no background fill; "tree" renders bold section headers with stroke-chevron folds and single-hairline connectors with a soft active fill. Propagated to items via context and exposed as data-variant on the nav root.
autoTonebooleanfalseWhen true, renders a small intent dot before each item label and derives intent from the item value via the built-in dictionary unless overridden by an explicit intent prop on the item. Color is decoration only (WCAG 1.4.1) — pair with a text/glyph cue.
embeddedbooleanfalseWhen true, keeps navigation inline on mobile instead of portaling into the built-in Dialog sheet. Use when a parent shell already owns the mobile drawer or overlay.
childrenrequiredReactNodeSidebar subparts (Header, Content, Footer, Trigger).
NameTypeDefaultDescription
state"open" | "rail" | "hidden"Documented exception to the `value`/`onChange` control convention: the sidebar exposes a tri-state value (`"open" | "rail" | "hidden"`), so the boolean `collapsed`/`onCollapsedChange` shape cannot represent it. `state` and `onStateChange` are the semantic names for this tri-state control.
defaultState"open" | "rail" | "hidden""open"Initial visibility state for uncontrolled use. Below the breakpoint this value is not used for presentation — the mobile sheet has its own internal open state that always starts closed — but it is preserved untouched for the next desktop layout.
onStateChange(state: "open" | "rail" | "hidden") => voidFired when the visibility state changes (controlled and uncontrolled).
breakpointnumber1024Viewport width (px) below which the sidebar collapses into a mobile sheet. Default matches Tailwind lg.
shortcutKeystring | null"b"Case-insensitive hotkey. On desktop Cmd/Ctrl+<key> cycles open ↔ rail and Shift+Cmd/Ctrl+<key> toggles hidden. On mobile both combinations toggle the sheet, and neither writes the desktop state. Pass null to disable.
childrenrequiredReactNodeSidebar and main content that need access to the state via useSidebar().
NameTypeDefaultDescription
aria-labelstring"Expand sidebar" / "Collapse sidebar" / "Open navigation" / "Close navigation"Accessible name. Defaults to a state- and device-derived label.
childrenReactNode"[≡]" (collapsed) / "[×]" (open)Trigger button content (typically an icon).
NameTypeDefaultDescription
childrenReactNodeTop region content.
NameTypeDefaultDescription
childrenReactNodeScrollable middle region. The complete Sidebar nav shell becomes aria-hidden and inert in the hidden state.
NameTypeDefaultDescription
collapsiblebooleanfalseWhen true, Sidebar.SectionTitle becomes a disclosure toggle that expands/collapses the section.
openbooleanControlled open state for the section.
defaultOpenbooleantrueInitial open state for the section.
onOpenChange(open: boolean) => voidFired when the section open state changes.
childrenReactNodeSection subparts (SectionTitle, SectionContent, Items).
NameTypeDefaultDescription
headingLevel"h1" | "h2" | "h3" | "h4" | "h5" | "h6""h3"Heading level rendered for the section title. Default h3 keeps screen-reader heading-rotor navigation predictable. Collapsible sections wrap a button with aria-expanded/aria-controls inside the heading.
handleReactNode | null<Chevron open={isOpen} size="sm" />Custom handle element for collapsible sections. Pass null to hide.
childrenReactNodeSection label.
NameTypeDefaultDescription
childrenReactNodePanel for collapsible sections. Wires the section title's aria-controls, stays mounted for animation, and becomes aria-hidden and inert while closed. For non-collapsible sections this is a no-op wrapper and may be omitted.
NameTypeDefaultDescription
as"a" | "button""a"Rendered element. Items are navigation links by default; pass as="button" for non-navigation actions.
activebooleanfalseMarks the item as the current page. Adds aria-current="page", data-selected, and the variant's active styling.
valuestringauto-generated idStable identifier exposed as data-value for keyboard navigation and intent lookup.
intent"neutral" | "info" | "success" | "warning" | "danger" | "accent"undefined (or auto from value when autoTone is enabled)Item intent. Renders as data-intent and tints the auto-tone dot from semantic tokens: neutral → --muted-foreground, info → --info-strong, success → --success-strong, warning → --warning-strong, danger → --error-strong, accent → --action. When autoTone is enabled on Sidebar and intent is omitted, it is inferred from value via the built-in dictionary. Color is decoration only — pair with a text/glyph cue.
disabledbooleanfalseDisables the item. Adds aria-disabled and removes from tab order.
childrenrequiredReactNode | (props: SidebarItemRenderProps<HTMLAnchorElement>) => ReactNodeSidebar subparts/item content or a render function (for framework Link components) that receives ref, className, disabled, aria-current, aria-disabled, data-selected, data-intent, data-value, onClick, tabIndex, and itemPrefix. itemPrefix is a ReactNode (intent dot, variant glyph) that must be rendered as the element's leading content, never spread onto the element.
NameTypeDefaultDescription
childrenReactNodeTruncated label text.
NameTypeDefaultDescription
childrenReactNodeTrailing badge or metadata (e.g. count, status).
NameTypeDefaultDescription
childrenReactNodeBottom region content.

Data attributes

AttributeApplies toValuesDescription
data-stateSidebar / Sidebar.Content"open" | "rail" | "hidden"Current provider visibility state used for root and content styling.
data-stateSidebar.Trigger"open" | "collapsed"Binary visual state: open when the sidebar is visible at full width, collapsed for desktop rail and hidden states.
data-stateSidebar.Section / Sidebar.SectionContent"open" | "closed"Collapsible section disclosure state.
data-variantSidebar"caret" | "inverted" | "bar" | "terminal" | "tree"Active-marker variant applied to the nav root and consumed by descendants.
data-selectedSidebar.Itempresent when activeMarks the current page/action row.
data-intentSidebar.Item"neutral" | "info" | "success" | "warning" | "danger" | "accent"Optional or auto-derived tone for the decorative intent dot.
data-valueSidebar.Itemitem valueStable navigation and auto-tone lookup value.
data-auto-toneSidebarpresent when enabledMarks roots that derive item intent from values.
data-mobileSidebar mobile sheet"true"Marks the off-canvas mobile sheet instance.

Accessibility

Keyboard Navigation

Sidebar.Content owns roving arrow navigation for visible items. SidebarProvider also binds the global Cmd/Ctrl+B shortcut by default and skips editable targets.

KeyAction
ArrowUp / ArrowDownMoves focus to the previous or next visible Sidebar.Item.
Home / EndMoves focus to the first or last visible item.
Enter / SpaceActivates a focused button item; links use their native activation behavior.
Cmd/Ctrl+BCycles desktop open ↔ rail, or opens/closes the mobile sheet.
Shift+Cmd/Ctrl+BToggles the desktop hidden state, or opens/closes the mobile sheet.

Notes

Current-location mark

The library spells "you are here" one way: a 2px left rail in --primary (registry/lib/marker-rail.ts). Full-bleed inversion is reserved for the TRANSIENT keyboard highlight; a row that is both the current location and the highlight keeps the inversion and flips its rail to --primary-foreground so the mark survives. The rail is reserved transparently in the resting state and pulled back by its own width, so a row's label never shifts horizontally when it becomes current — that anti-shift geometry is the contract, and it is why the rail costs 0px of label width at 375/390 where a full-bleed fill reads as a solid slab. The `bar` variant is that shared rail. `caret`, `terminal` and `tree` are deliberate alternative idioms with their own gutter grammar, and `inverted` is the loud full-bleed option; pick `bar` when you want the library-standard mark.

Requires @diffgazer/keys (package mode)

Sidebar's arrow-key navigation imports from the required @diffgazer/keys peer. Package consumers must install @diffgazer/keys with @diffgazer/ui; packages are not yet published to npm, so until the first release install both from locally packed tarballs. Importing @diffgazer/ui/components/sidebar without keys fails at module load with an error naming the missing @diffgazer/keys package. Copy/dgadd consumers do not need the package — copy mode rewrites the keyboard hooks to local source.

Tri-state visibility

Sidebar tracks three states via SidebarProvider: "open" (full width), "rail" (collapsed glyph rail, desktop only), and "hidden" (off-canvas). Below the breakpoint the tri-state describes the desktop layout only — the sheet is driven by its own internal open state, so a desktop → mobile → desktop round trip restores the exact state it started with. Hidden desktop navigation marks the complete nav shell aria-hidden and inert, including Header, Content, and Footer. Keep a SidebarTrigger outside that shell or use the provider hotkey so users can reopen it. The provider exposes state/defaultState/onStateChange and helpers toggleSidebar (desktop open ↔ rail) and toggleHidden (desktop open ↔ hidden); on mobile both toggle the sheet instead.

Mobile sheet

Below the breakpoint (default 1024px) the nav renders inside a Dialog sheet anchored to the viewport's left edge. The sheet starts closed and opens from SidebarTrigger, the global hotkey, or useSidebar().onMobileOpenChange. No mobile transition — entering the sheet, opening it, closing it, or leaving for desktop — emits onStateChange. state and defaultState control the desktop presentation only, so a controlled parent pinning a value does not block the sheet. The breakpoint is configurable on SidebarProvider.

Global hotkey

An explicit SidebarProvider binds a configurable global shortcut. On desktop Cmd/Ctrl+<key> cycles open ↔ rail and Shift+Cmd/Ctrl+<key> toggles hidden; on mobile both toggle the sheet and neither writes the desktop state. Default key is "b" (VS Code convention). Pass shortcutKey={null} to disable. A Sidebar used without a provider never binds the hotkey — global keys are an app-level contract, so opt in by mounting the provider. Editable targets (input/textarea/contenteditable/select) skip the handler.

SidebarProvider vs standalone

Wrap Sidebar in SidebarProvider when you need to control state from outside the sidebar, react to it elsewhere (e.g. a header trigger), or enable the global hotkey. When Sidebar is used without a provider it instantiates one internally without the hotkey. Read state, isMobile, openMobile, onStateChange, and onMobileOpenChange from any descendant via useSidebar().

Visual variants

Five active-marker variants share one rule — structure is permanent, markers appear on the active row: caret (reserved chevron marker slot; invisible at rest, dim on hover, foreground on active), inverted (full-bleed bg-foreground row), bar (2px border-l plus soft fill on active), terminal (chevron prompt on active; 1px hairline left rail, no bg fill), tree (bold section headers with stroke-chevron folds; single-hairline CSS connectors — trunk/tick/corner — with soft active fill). Selected via <Sidebar variant=…> and propagated to items via context. Exposed as data-variant on the nav root.

Auto-tone (intent dot)

Opt-in via <Sidebar autoTone>. Renders a small dot before each item label whose color is derived from the item value through a built-in dictionary (added → success, deleted → danger, modified → warning, etc.) or from an explicit intent prop on SidebarItem. Color is decoration only (WCAG 1.4.1) — always pair with a text/glyph cue (the label, a badge).

Collapsible sections

SidebarSection accepts collapsible. SidebarSectionTitle renders an ARIA disclosure (h3 wrapping a button with aria-expanded/aria-controls). A title rendered through an opaque consumer wrapper registers after mount so the role=group keeps its accessible name; for server-rendered opaque wrappers, give the heading a stable id and pass it to Sidebar.Section through aria-labelledby. SidebarSectionContent stays mounted for the close animation and becomes aria-hidden and inert while closed. Supports controlled open/onOpenChange or uncontrolled defaultOpen.

Rail mode naming

In rail state the visible label and badge collapse to display:none, leaving an icon-only row. SidebarItem automatically preserves the accessible name by rendering an sr-only copy of its label content that appears in the accessibility tree only while the sidebar is in rail state, so icon-only links and buttons keep a non-empty name. Render-prop items own their own markup, so supply an aria-label (or sr-only text) for the rail state yourself.

Item render props

SidebarItem supports a render-prop children for custom elements (e.g. framework Link components). The render function receives ref, className, disabled, aria-current, aria-disabled, data-selected, data-intent, data-value, onClick, tabIndex, and itemPrefix — a ReactNode carrying the intent dot and variant glyph. Destructure itemPrefix and render it as the element's leading content; never spread it onto the element.

SSR persistence

The provider exports SIDEBAR_STATE_COOKIE but intentionally does not read or write it. SSR frameworks should parse the cookie in their loader, pass the resolved value as defaultState, and mirror onStateChange writes back via document.cookie (SameSite=Lax, 1y). Mirroring is unconditional: mobile transitions never fire it, so the stored desktop preference survives a phone visit.

Source

Install via CLI: pnpm exec dgadd add ui/sidebar. Keyboard hooks are included as standalone copies. For the full experience, use --integration keys.

Highlighted source loads after this disclosure opens. Browse the source repository.