blocksection
Side navigation
Docs-style side nav with collapsible groups, one named landmark, and a drawer below lg.
Preview
Installation
pnpm dlx shadcn@latest add https://facadeui.dev/r/nav-side.jsonPulls in @base-ui-components/react@1.0.0-rc.0, lucide-react@^1.47.0, button, heading, types, utils. The CLI installs them for you.
Usage
The exact source of the preview above.
import { NavSide } from "@registry/sections/nav-side"
const groups = [
{
title: "Getting started",
links: [
{ label: "Installation", href: "#installation" },
{ label: "Theming", href: "#theming" },
{ label: "Accessibility", href: "#accessibility" },
],
},
{
title: "Atoms",
collapsible: true,
links: [
{ label: "Button", href: "#button" },
{ label: "Badge", href: "#badge" },
{ label: "Input", href: "#input" },
],
},
{
title: "Sections",
collapsible: true,
defaultCollapsed: true,
links: [
{ label: "Hero", href: "#hero" },
{ label: "Feature grid", href: "#feature-grid" },
{ label: "FAQ", href: "#faq" },
],
},
]
export function Demo() {
return (
<div className="grid gap-8 lg:grid-cols-[16rem_minmax(0,1fr)]">
<NavSide label="Documentation" groups={groups} currentPath="#theming" />
<div className="text-muted-foreground text-sm">
Below <code>lg</code> the sidebar collapses to a drawer. The group holding the
current page is always expanded, even when it is marked{" "}
<code>defaultCollapsed</code>.
</div>
</div>
)
}Source
What the CLI copies into your project, byte for byte.
"use client"
/**
* NavSide — the docs- and product-style side navigation.
*
* Groups of links, optionally collapsible, with the current page marked. Below
* `lg` it collapses into the same Base UI Dialog drawer pattern `NavTop` uses,
* because a fixed sidebar on a phone is just lost width.
*
* a11y:
*
* - One `<nav>` with an accessible name, and group headings inside it. Four
* separate `<nav>` landmarks — one per group — is the more common shape and
* the worse one: landmark navigation then lists four items called
* "navigation" with nothing to tell them apart.
* - Groups are real headings, so heading navigation works down the sidebar.
* - Collapsible groups use Base UI's Collapsible, so the trigger is a button
* inside the heading with `aria-expanded` and `aria-controls` wired up.
* - The current page carries `aria-current="page"`, announced rather than only
* coloured. Its ancestor group is expanded on first render, so the active
* item is never hidden inside a collapsed section.
* - The sidebar scrolls independently and the drawer traps focus.
*
* Dependencies: @base-ui-components/react, lucide-react, react,
* @/lib/types, @/lib/utils, @/components/ui/button,
* @/components/ui/heading.
*/
import { Collapsible } from "@base-ui-components/react/collapsible"
import { Dialog } from "@base-ui-components/react/dialog"
import { ChevronDownIcon, MenuIcon, XIcon } from "lucide-react"
import { useState, type ElementType, type ReactNode } from "react"
import type { HeadingLevel, LinkComponent } from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Heading } from "@/components/ui/heading"
export interface NavSideLink {
label: string
href: string
/** Small trailing tag — "New", "Beta". */
badge?: ReactNode
external?: boolean
}
export interface NavSideGroup {
title: string
links: NavSideLink[]
/** Makes the group a Base UI Collapsible. */
collapsible?: boolean
/** Start collapsed. Ignored when the group holds the current page. */
defaultCollapsed?: boolean
}
export interface NavSideProps {
groups: NavSideGroup[]
/** Names the landmark, e.g. "Documentation". */
label?: string
currentPath?: string
link?: LinkComponent
/** Outline level of the group headings. Defaults to `2`. */
headingLevel?: HeadingLevel
/** Rendered above the groups — a version switcher, a search trigger. */
children?: ReactNode
/** Offset for the sticky sidebar, to clear a fixed header. */
stickyTop?: string
className?: string
id?: string
}
const linkClass =
"focus-visible:ring-ring flex min-h-9 items-center justify-between gap-2 rounded-md px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
function NavLinks({
group,
currentPath,
link,
onNavigate,
}: {
group: NavSideGroup
currentPath?: string
link?: LinkComponent
onNavigate?: () => void
}) {
const Link = (link ?? "a") as ElementType
return (
<ul className="flex flex-col gap-0.5">
{group.links.map((item) => {
const active = currentPath === item.href
return (
<li key={item.href}>
<Link
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
{...(item.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={cn(
linkClass,
active
? "bg-secondary text-secondary-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-accent/60",
)}
>
<span>{item.label}</span>
{item.badge}
{item.external ? (
<span className="sr-only"> (opens in a new tab)</span>
) : null}
</Link>
</li>
)
})}
</ul>
)
}
function NavGroups({
groups,
currentPath,
link,
headingLevel,
onNavigate,
}: {
groups: NavSideGroup[]
currentPath?: string
link?: LinkComponent
headingLevel: HeadingLevel
onNavigate?: () => void
}) {
return (
<div className="flex flex-col gap-7">
{groups.map((group) => {
const holdsCurrent = group.links.some((item) => item.href === currentPath)
const headingId = slugId(group.title, "nav")
if (!group.collapsible) {
return (
<div key={group.title} className="flex flex-col gap-1.5">
<Heading
level={headingLevel}
id={headingId}
className="text-foreground px-3 text-sm font-semibold"
>
{group.title}
</Heading>
<NavLinks
group={group}
currentPath={currentPath}
link={link}
onNavigate={onNavigate}
/>
</div>
)
}
return (
<Collapsible.Root
key={group.title}
// Never start collapsed around the page the user is on.
defaultOpen={holdsCurrent || !group.defaultCollapsed}
className="flex flex-col gap-1.5"
>
<Heading level={headingLevel} id={headingId}>
<Collapsible.Trigger className="text-foreground hover:bg-accent/60 focus-visible:ring-ring group flex min-h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md px-3 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2">
{group.title}
<ChevronDownIcon
aria-hidden
focusable="false"
className="text-muted-foreground duration-facade-fast ease-facade-out size-4 transition-transform group-data-[panel-open]:rotate-180"
/>
</Collapsible.Trigger>
</Heading>
<Collapsible.Panel className="duration-facade-base ease-facade-out h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] data-[ending-style]:h-0 data-[starting-style]:h-0">
<NavLinks
group={group}
currentPath={currentPath}
link={link}
onNavigate={onNavigate}
/>
</Collapsible.Panel>
</Collapsible.Root>
)
})}
</div>
)
}
export function NavSide({
groups,
label = "Section",
currentPath,
link,
headingLevel = 2,
children,
stickyTop = "5rem",
className,
id,
}: NavSideProps) {
const [drawerOpen, setDrawerOpen] = useState(false)
return (
<>
<nav id={id} aria-label={label} className={cn("hidden lg:block", className)}>
<div
className="sticky overflow-y-auto pb-10 pr-4"
style={{ top: stickyTop, maxHeight: `calc(100dvh - ${stickyTop} - 1rem)` }}
>
{children ? <div className="mb-6">{children}</div> : null}
<NavGroups
groups={groups}
currentPath={currentPath}
link={link}
headingLevel={headingLevel}
/>
</div>
</nav>
<Dialog.Root open={drawerOpen} onOpenChange={setDrawerOpen}>
<Dialog.Trigger
aria-label={`Open ${label.toLowerCase()} navigation`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "lg:hidden")}
>
<MenuIcon aria-hidden focusable="false" className="size-4" />
{label}
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop className="duration-facade-base fixed inset-0 z-50 bg-black/50 transition-opacity data-[ending-style]:opacity-0 data-[starting-style]:opacity-0" />
<Dialog.Popup className="bg-background duration-facade-base ease-facade-out fixed inset-y-0 left-0 z-50 flex w-[min(20rem,85vw)] flex-col gap-6 overflow-y-auto border-r p-6 transition-transform data-[ending-style]:-translate-x-full data-[starting-style]:-translate-x-full">
<div className="flex items-center justify-between gap-4">
<Dialog.Title className="text-base font-semibold">{label}</Dialog.Title>
<Dialog.Close
aria-label="Close navigation"
className={buttonVariants({ variant: "ghost", size: "icon" })}
>
<XIcon aria-hidden focusable="false" />
</Dialog.Close>
</div>
<Dialog.Description className="sr-only">
Browse the pages in this section.
</Dialog.Description>
{children}
<nav aria-label={label}>
<NavGroups
groups={groups}
currentPath={currentPath}
link={link}
headingLevel={headingLevel}
onNavigate={() => setDrawerOpen(false)}
/>
</nav>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</>
)
}Props
NavSideProps
| Prop | Type | Default |
|---|---|---|
groups*Required | NavSideGroup[] | — |
labelNames the landmark, e.g. "Documentation". | string | "Section" |
currentPath | string | — |
link | LinkComponent | — |
headingLevelOutline level of the group headings. Defaults to `2`. | HeadingLevel | 2 |
childrenRendered above the groups — a version switcher, a search trigger. | ReactNode | — |
stickyTopOffset for the sticky sidebar, to clear a fixed header. | string | "5rem" |
className | string | — |
id | string | — |