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
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.
The hosted registry is not public yet because r.b4r7.dev does not resolve. Use this source checkout or a local registry preview until the endpoint returns 200.
Diffgazer packages are not yet published to npm. Until the first release, pack @diffgazer/ui and @diffgazer/keys from the repository and install those tarballs.
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
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
Auto-tone intent dots
Render-prop items
API Reference
Sidebar
Sidebar.Provider
Sidebar.Trigger
Sidebar.Header
Sidebar.Content
Sidebar.Section
Sidebar.SectionTitle
Sidebar.SectionContent
Sidebar.Item
Sidebar.ItemLabel
Sidebar.ItemBadge
Sidebar.Footer
Data attributes
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.
| Key | Action |
|---|---|
| ArrowUp / ArrowDown | Moves focus to the previous or next visible Sidebar.Item. |
| Home / End | Moves focus to the first or last visible item. |
| Enter / Space | Activates a focused button item; links use their native activation behavior. |
| Cmd/Ctrl+B | Cycles desktop open ↔ rail, or opens/closes the mobile sheet. |
| Shift+Cmd/Ctrl+B | Toggles 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.