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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/sidebar-nav.jsonPrompt
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-expandedbutton; collapsing unmounts the rows instead of hiding them, so a closed section costs zero Tab stops whileAnimatePresencestill animates the height on the way out. - Collapsed rail — the same
groupsarray renders as an icon-only strip: labels migrate intoaria-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, markedaria-hiddenbecause 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 selection —
value/onValueChangemeans the highlight can be owned byusePathname()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.
TOC Scrollspy
An article outline that follows the reader — headings are watched with one IntersectionObserver and a rail slides to the current section.
Link Preview
An inline link that reveals a hoverable preview card — thumbnail, title, description and domain — with edge-aware flipping and touch-safe degradation.