Navigation

Sidebar Nav

A grouped app sidebar with collapsible sections, icons, badges, an active-item indicator and an icon-only collapsed rail.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"

export interface SidebarNavItem {
  key: string
  label: string
  /** Leading glyph. Sized to 16px by the row wrapper, so pass a bare icon element. */
  icon?: React.ReactNode
  /** Count or short status; degrades to a dot marker in collapsed mode. */
  badge?: string | number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/sidebar-nav.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "SidebarNav" component using
lucide-react for the disclosure chevron and motion/react for the
collapse transition.

Contract
- export const SidebarNav = React.forwardRef<HTMLElement, SidebarNavProps>
  rendering a <nav aria-label>; spread remaining native props on the root
  and merge className via cn(). aria-label defaults to "Sidebar" and is
  overridable through the normal prop.
- SidebarNavItem = { key: string; label: string; icon?: React.ReactNode;
  badge?: string | number; href?: string; onSelect?: (key: string) => void;
  disabled?: boolean }.
- SidebarNavGroup = { key: string; label?: string; collapsible?: boolean;
  defaultOpen?: boolean; items: SidebarNavItem[] } — omit `label` for an
  unlabelled block of rows; `collapsible` requires a label because the label
  is what becomes the disclosure button.
- groups: SidebarNavGroup[] plus a fully controlled selection pair:
  value?: string (the active item's key) + onValueChange?: (key) => void.
  The component holds no selection state of its own; the consumer decides
  whether that key comes from local state or from the router's pathname.
- collapsed?: boolean (default false) switches the whole nav to an
  icon-only rail. header? / footer? are ReactNode slots rendered above the
  groups and pinned to the bottom (mt-auto) respectively.
- Width is NOT a prop — the consumer sets it via className (e.g. w-64
  expanded, w-16 collapsed), so the rail can be animated or made responsive
  by the layout that owns it.

Behavior
- Group disclosure: a collapsible group's label renders as a
  <button type="button" aria-expanded aria-controls>, with a trailing
  ChevronDown that rotates -90deg when closed. Open state is internal, seeded
  from defaultOpen (default true); store only the *deviations* from
  defaultOpen in a Record<string, boolean> so swapping the groups array never
  needs a sync effect. A non-collapsible labelled group renders its label as
  a plain static caption instead — no button, no chevron.
- Collapsing unmounts the rows rather than hiding them, so collapsed items
  leave the Tab order completely; AnimatePresence + animating height
  0 <-> "auto" (with overflow-hidden) keeps the transition on the way out.
  useReducedMotion() drops the duration to 0 so the section snaps instead of
  sliding — the group still opens and closes either way.
- Row element is chosen by what the consumer actually wired up:
  href -> a real <a href>; otherwise onSelect or a root-level onValueChange
  -> <button type="button">; neither -> a plain <span> row with no hover
  affordance. Never render a clickable-looking row with nothing behind it.
  disabled always wins and renders <button disabled> (natively unfocusable),
  even when an href is present, because an <a> cannot be disabled.
- Activation calls onValueChange(item.key) first, then item.onSelect(key);
  on an <a> the navigation still happens, so the callback acts as an
  optimistic highlight for router-driven consumers.
- Active row: bg-accent + text-accent-foreground + font-medium, a 2px
  primary bar absolutely positioned at the row's left edge (aria-hidden),
  and aria-current="page".
- Collapsed rail: rows drop their text and center the icon; the label moves
  into aria-label (concatenated with the badge value, e.g. "Inbox, 12", so
  the count is not lost). A group's label degrades to a thin horizontal
  divider — skipped for the first group so the rail never opens with a rule.
  Collapsible groups are always fully expanded in the rail, since there is
  no room for a disclosure header.
- Collapsed flyout: a self-drawn absolutely-positioned label appears to the
  right on group-hover/row and group-focus-visible/row (opacity + scale
  transition, motion-reduce:transition-none). It is aria-hidden and
  pointer-events-none — the accessible name already lives on the row, so
  this layer is purely visual and needs no popover library. The nav must not
  be given overflow-hidden by the layout, or the flyout gets clipped.
- Badges: inline pill on the right when expanded; in the rail they degrade
  to a small primary dot in the row's top-right corner (ring-2 ring-background
  so it reads against the row).
- Long labels truncate with an ellipsis (min-w-0 + flex-1 + truncate) rather
  than wrapping a row to two lines.

Rendering & styling
- Semantic tokens only: bg-accent / text-accent-foreground (active),
  hover:bg-muted + hover:text-foreground (idle hover), text-muted-foreground
  (idle), bg-primary (active bar, dot, badge on active),
  bg-popover / text-popover-foreground + border (flyout), bg-border
  (divider), ring-ring (focus). No hex/oklch/rgb literals anywhere.
- Every interactive element gets focus-visible:ring-2 focus-visible:ring-ring
  and outline-none; decorative nodes (indicator bar, dot, icon wrapper,
  divider, flyout) are aria-hidden.
- Icons are passed in as ReactNode and normalised to 16px by a
  [&_svg]:size-4 wrapper, so any icon set works without a size prop.

Customization levers
- Density: the row's py-1.5 / gap-2.5 / px-2 and the group gap-4 are the
  three knobs; tighten them for an IDE-style rail, loosen for touch.
- Rail width & animation: the consumer's className owns the width — add a
  transition-[width] on the wrapper to animate expand/collapse, or drive
  `collapsed` from a media query for a responsive shell.
- Router integration: derive `value` from usePathname() and swap the plain
  <a> for next/link (or your router's Link) inside the row renderer; the
  href / button / plain-row branching stays untouched.
- Flyout side: the collapsed label is positioned with left-full ml-2 —
  mirror to right-full mr-2 for a right-hand rail (RTL or inspector panels).
- Badge semantics: swap the bg-muted pill for bg-destructive /
  text-destructive-foreground when the badge means "needs attention" rather
  than "count".
- Nested (second-level) items are intentionally out of scope: this is a
  two-level model (group -> item). For arbitrary depth use a tree component
  instead of nesting groups.
- Sub-slots: header/footer take any ReactNode — workspace switcher, search
  trigger, plan badge, account chip — and are the intended extension point
  instead of adding props for each.

Concepts

  • Group disclosure — a section title doubles as its own aria-expanded button; collapsing unmounts the rows instead of hiding them, so a closed section costs zero Tab stops while AnimatePresence still animates the height on the way out.
  • Collapsed rail — the same groups array renders as an icon-only strip: labels migrate into aria-label, badges shrink to a dot, and group titles degrade to dividers, so switching modes never means maintaining a second nav definition.
  • Hover flyout label — the rail's label bubble is a plain absolutely-positioned span driven by group-hover / group-focus-visible, marked aria-hidden because the row already carries the name — a tooltip's job without a tooltip dependency.
  • Active indicator — the current row is signalled three ways at once: an accent fill, a 2px primary bar at the left edge, and aria-current="page", so it reads for sighted, low-vision and screen-reader users alike.
  • Controlled selectionvalue / onValueChange means the highlight can be owned by usePathname() in a real app and by local state in a demo, with no internal state to fall out of sync.
  • No dead rows — the row element is chosen from what the consumer actually supplied (href → anchor, callback → button, neither → plain span), so the component can never render something that looks clickable but isn't.

On This Page