Nav
A navigation list of links with icon slots and a sliding active pill, horizontal or vertical.
Installation
Usage
import { Nav, NavList, NavSection, NavLabel, NavItem } from "@/components/ui/nav"
import { resolveActiveHref } from "@/components/ui/nav-active"Examples
Default
Horizontal, the top nav pattern. Pass href and handle the click yourself, or swap in a router link with render.
Sizes
size steps items along the control scale: sm (h-7, 12px), default (h-8, 13px), lg (h-10, 14px). One prop on Nav sizes every item, label, and count chip, in both orientations.
With Count
Pass a count to add a counter chip, the same one a TabsTrigger carries: solid accent on the active item, a neutral tint on the rest. size on Nav scales it with the items. Cap large counts at "99+" so a growing number cannot widen the item.
In a header
Nav shares the kit's control heights, so a header lines up when every control in the row is set to the same size: sm is 28px, default 32px, lg 40px, matching Button tier for tier.
Vertical
orientation="vertical" groups items into NavSections with a NavLabel heading, the sidebar pattern.
Vertical with Count
A count sits at the trailing edge of a vertical item, not against the label, so a column of numbers reads as a column. That needs a width on the Nav: items size to their own content, and there is no shared trailing edge to push a count to until you give them one.
Mobile
A composition, not a separate component: a Button opens a Drawer containing the vertical Nav.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| orientationNav | "horizontal" | "vertical" | "horizontal" | Lays out the list as a row (top nav) or a column (sidebar) and sizes items to match. |
| sizeNav | "sm" | "default" | "lg" | "default" | Steps items and labels along the control scale, from sm (h-7, 12px type) up to lg (h-10, 14px type). |
| iconNavItem | React.ReactNode | - | Leading icon rendered before the item's label, e.g. <Star />. |
| countNavItem | React.ReactNode | - | Counter chip after the label, matching the TabsTrigger chip tier for tier. Inline in a horizontal nav, pushed to the trailing edge in a vertical one. Add an aria-label where a bare number needs context (aria-label="Inbox, 12 unread"). |
| activeNavItem | boolean | - | Marks the item as the current page. Sets aria-current="page" and drives the sliding pill. |
| renderNavItem | React.ReactElement | - | Swaps the default <a> for another element, e.g. render={<Link href="/dashboard" />} to compose with a router. |
"use client"
import * as React from "react"
import {
createContext,
useContext,
useState,
useRef,
useEffect,
useLayoutEffect,
} from "react"
import { useRender } from "@base-ui/react/use-render"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/registry/lib/utils"
/* ─────────────────────────────────────────────────────────
* NAV
*
* A navigation list of real links, horizontal (top nav) or
* vertical (sidebar). This is link navigation, not content
* switching: Nav renders anchors that go somewhere, Tabs
* renders triggers that switch a panel in place. Reach for
* Tabs when the "active" thing is a view you're rendering;
* reach for Nav when it's a page you'd navigate to directly.
*
* The active item gets the kit's quiet-selected treatment as
* a single sliding pill, the same pattern as tabs.tsx: one
* persistent `motion.span` moved to the active item's box,
* never a per-item `layoutId`. See tabs.tsx's header comment
* for why a per-item shared element balloons across a long
* slide. Nav's pill differs from tabs' in one way: items can
* sit inside nested `li`/`ul`/section wrappers (vertical nav
* with grouped sections), so its box is measured with
* `getBoundingClientRect` deltas against the `Nav` root
* instead of `offsetLeft` against the immediate list.
*
* EXACTLY ONE item may carry `active`. The pill is a single
* element and takes the first `[data-active]` it finds, while
* every active item paints its own accent label colour, so a second
* one splits the selection in two: the pill sits on the first
* and the colour sits on both. Deriving `active` from a path
* PREFIX is how this happens, because section prefixes nest
* ("/docs" contains "/docs/components") and a page in the
* inner section matches both.
*
* Do not hand-roll that check. `resolveActiveHref` ships beside
* this file in `nav-active.ts` for exactly this: it returns the
* ONE href that wins, so comparing each item against it can
* only be true once.
*
* const activeHref = resolveActiveHref(pathname, hrefs)
* <NavItem active={item.href === activeHref} />
*
* It is a separate, directive-free module so a Server Component
* can call it; see the header comment there. `Nav` itself never
* reads the router.
*
* `pillReady` (context) is false until the pill's first
* successful measurement. While false, the active item paints
* its own static tint (see `NavItem`) so SSR and the first
* paint before layout effects run still show the selected
* item; the instant the pill lands in the same box, the static
* tint switches off and the pill takes over. The swap is
* invisible because both read the same tokens.
* ───────────────────────────────────────────────────────── */
// Paired with the item's own `transition-[color] duration-150`: the pill and
// the label are ONE selection, so they share a duration and land together.
// A 200ms spring under a 150ms color change made the destination read as
// selected while the pill was still two rows away. `bounce: 0` because a nav
// is chrome you click all day, and one bouncy control in a crisp kit reads as
// a mistake. Change one of these two values and you must change the other.
const PILL = {
spring: { type: "spring" as const, visualDuration: 0.15, bounce: 0 },
}
// useLayoutEffect warns during SSR; fall back to useEffect there.
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect
type NavOrientation = "horizontal" | "vertical"
type NavSize = "sm" | "default" | "lg"
// Sizes ride the kit's control scale (Button/Select tiers): type steps
// 12px / 13px / 14px, icons one step behind, horizontal items on the
// h-7 / h-8 / h-10 heights. Vertical items size from padding instead of a
// fixed height; the pointer-coarse guard keeps touch rows tall at any size.
const ROOT_SIZE: Record<NavSize, string> = {
sm: "text-1xs",
default: "text-xs",
lg: "text-sm",
}
// Only `sm` gets a pointer-coarse height bump on the horizontal row: at h-7 it
// is the one rung under the 32px floor, so it grows to 32. `default` (32) and
// `lg` (40) already clear it, and bumping them would reflow a dense nav for
// nothing. Same reasoning as button-variants.ts's short rungs. Vertical rows
// have no fixed height, so every size grows its padding on coarse instead.
//
// Side padding steps 8 / 12 / 12 rather than 8 / 10 / 12: 10px is off the 4px
// grid, and `default` is a 32px control carrying 13px type, the same object a
// 32px Button is, so it takes the same 12px inset. These values must stay equal
// to the tabs.tsx trigger of the same size: a tab and a nav item are one object.
// `label` is the icon-to-label gap INSIDE the label span, and it repeats the
// link's own gap: icon+label wrap together so the count can be their sibling
// (see NavItem), and the wrap must not change how the row reads.
const ITEM_SIZE: Record<
NavSize,
{ shared: string; horizontal: string; vertical: string; label: string }
> = {
sm: {
shared: "gap-1.5 [&_svg:not([class*='size-'])]:size-3",
horizontal: "h-7 px-2 pointer-coarse:min-h-8",
vertical: "px-2 py-1 pointer-coarse:py-2.5",
label: "gap-1.5",
},
default: {
shared: "gap-2 [&_svg:not([class*='size-'])]:size-3.5",
horizontal: "h-8 px-3",
vertical: "px-3 py-1 pointer-coarse:py-2.5",
label: "gap-2",
},
lg: {
shared: "gap-2 [&_svg:not([class*='size-'])]:size-4",
horizontal: "h-10 px-3",
vertical: "px-3 py-1.5 pointer-coarse:py-2.5",
label: "gap-2",
},
}
// Identical to tabs.tsx's COUNT_SIZE, and it must stay identical: a nav item
// and a tab of the same size are one object, so their count chips are too.
// Change one file, change the other.
const COUNT_SIZE: Record<NavSize, string> = {
sm: "h-4 min-w-4 px-1 text-2xs [&_svg]:!size-2.5",
default: "h-4 min-w-4 px-1 text-2xs [&_svg]:!size-2.5",
lg: "h-5 min-w-5 px-1.5 text-1xs [&_svg]:!size-3",
}
// Radius follows the item's HEIGHT, not its size name, and reads from the
// action ramp Button uses: each `rounded-action-*` rung is 3/8 of its own
// height, so every row reads as one shape at three scales. A single fixed
// corner cannot do that, since roundness is seen as a fraction of the box.
// Vertical rows size from padding rather than a fixed height, so they land a
// step shorter than their horizontal twin at the same size name and take the
// rung below it. The sliding indicator reads from this same map: it is the
// fill sitting behind the item, so a mismatched corner shows as a crescent at
// each end.
const ITEM_RADIUS: Record<NavSize, Record<NavOrientation, string>> = {
// 28px horizontal / ~24px vertical
sm: { horizontal: "rounded-action-sm", vertical: "rounded-action-xs" },
// 32px horizontal / ~28px vertical
default: { horizontal: "rounded-action", vertical: "rounded-action-sm" },
// 40px horizontal / ~33px vertical
lg: { horizontal: "rounded-action-lg", vertical: "rounded-action" },
}
// Label padding tracks the item padding so the label stays flush with the
// item text; the micro-cap steps one type tier behind the items.
const LABEL_SIZE: Record<NavSize, string> = {
sm: "px-2 text-2xs",
default: "px-3 text-1xs",
lg: "px-3 text-xs",
}
const NavContext = createContext<{
orientation: NavOrientation
size: NavSize
pillReady: boolean
}>({ orientation: "horizontal", size: "default", pillReady: false })
// @use-when a row or column of navigation items with a current-page state.
function Nav({
orientation = "horizontal",
size = "default",
className,
children,
...props
}: React.ComponentProps<"nav"> & {
orientation?: NavOrientation
size?: NavSize
}) {
const navRef = useRef<HTMLElement>(null)
const [pill, setPill] = useState<{
x: number
y: number
width: number
height: number
} | null>(null)
const [pillReady, setPillReady] = useState(false)
const shouldReduceMotion = useReducedMotion()
useIsomorphicLayoutEffect(() => {
const root = navRef.current
if (!root) return
const measure = () => {
const active = root.querySelector<HTMLElement>(
'[data-slot="nav-item-link"][data-active]'
)
if (!active) {
setPill(null)
return
}
// Items can live inside li/ul/section wrappers, so an offsetLeft walk
// against the list isn't enough: measure both boxes in viewport space
// and take the delta against the nav root, the pill's own anchor. The
// root can scroll (horizontal overflow), and the pill lives in its
// CONTENT space, so fold the scroll offset back in; the position then
// stays correct at any scroll without re-measuring.
const rootRect = root.getBoundingClientRect()
const activeRect = active.getBoundingClientRect()
setPill({
x: activeRect.left - rootRect.left + root.scrollLeft,
y: activeRect.top - rootRect.top + root.scrollTop,
width: activeRect.width,
height: activeRect.height,
})
setPillReady(true)
}
measure()
// Items resize on font load, container resize, and count changes.
const observer = new ResizeObserver(measure)
observer.observe(root)
root
.querySelectorAll('[data-slot="nav-item-link"]')
.forEach((el) => observer.observe(el))
return () => observer.disconnect()
}, [children])
return (
<NavContext.Provider value={{ orientation, size, pillReady }}>
<nav
ref={navRef}
data-slot="nav"
data-orientation={orientation}
className={cn(
"relative",
ROOT_SIZE[size],
// A horizontal nav wider than its container scrolls instead of
// clipping items, same treatment as TabsList.
orientation === "horizontal" &&
"max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className
)}
{...props}
>
{/* Rendered before the items so they paint over it. */}
{pill && (
<motion.span
aria-hidden
data-slot="nav-indicator"
className={cn(
"pointer-events-none absolute top-0 left-0 bg-accent-100 dark:bg-accent-800",
// Must stay equal to the item's own radius (ITEM_RADIUS).
// Change one, change the other.
ITEM_RADIUS[size][orientation]
)}
// initial={false}: adopt the first measurement instead of
// animating in from x:0/y:0/width:0/height:0 on mount.
initial={false}
animate={{ x: pill.x, y: pill.y, width: pill.width, height: pill.height }}
transition={shouldReduceMotion ? { duration: 0 } : PILL.spring}
/>
)}
{children}
</nav>
</NavContext.Provider>
)
}
function NavList({ className, ...props }: React.ComponentProps<"ul">) {
const { orientation } = useContext(NavContext)
return (
<ul
data-slot="nav-list"
className={cn(
orientation === "horizontal"
? "flex items-center gap-1"
: "flex flex-col gap-0.5",
className
)}
{...props}
/>
)
}
function NavSection({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="nav-section"
className={cn("mb-(--spacing-rhythm-peer) last:mb-0", className)}
{...props}
/>
)
}
function NavLabel({ className, ...props }: React.ComponentProps<"div">) {
const { size } = useContext(NavContext)
return (
<div
data-slot="nav-label"
className={cn(
"mb-(--spacing-rhythm-label) font-semibold tracking-wide text-foreground uppercase",
LABEL_SIZE[size],
className
)}
{...props}
/>
)
}
// Polymorphic link. Renders an <a> by default; pass `render={<Link />}`
// (or any element) to compose with a router. Never uses `asChild`.
function NavItem({
icon,
count,
active,
className,
render,
children,
...props
}: useRender.ComponentProps<"a"> & {
icon?: React.ReactNode
count?: React.ReactNode
active?: boolean
}) {
const { orientation, size, pillReady } = useContext(NavContext)
return (
<li data-slot="nav-item">
{useRender({
render: render ?? <a />,
props: {
"data-slot": "nav-item-link",
"data-active": active ? true : undefined,
"aria-current": active ? "page" : undefined,
className: cn(
// `transition-[color]`, NOT `transition-colors`: background-color
// must NOT transition here. The indicator renders before the items
// so they paint over it, which means a hover fill that fades out
// sits ON TOP of the arriving pill and muddies it. Clearing the
// fill instantly also matches the frequency principle: a nav item
// is a 100+/day hover, and those should not animate.
"relative flex items-center font-medium whitespace-nowrap outline-none transition-[color] duration-150 focus-visible:ring-ring-focus focus-visible:ring-[3px] [&_svg]:shrink-0",
ITEM_RADIUS[size][orientation],
ITEM_SIZE[size].shared,
// `justify-between` rather than `justify-start`, same move as the
// vertical tabs trigger: the count is a SIBLING of the label span,
// so between pushes it to the trailing edge, which is how a column
// of counts is read (every mail sidebar does this). With no count
// there is only one child, and between puts a lone child at the
// start, so an item without one is unaffected and nothing reflows.
orientation === "horizontal"
? ITEM_SIZE[size].horizontal
: cn("w-full justify-between", ITEM_SIZE[size].vertical),
active
? cn("text-accent dark:text-accent-400", !pillReady && "bg-accent-100 dark:bg-accent-800")
: "text-muted-foreground hover:text-foreground hover:bg-neutral-500/10",
className
),
children: (
<>
<span
className={cn(
"inline-flex items-center",
ITEM_SIZE[size].label
)}
>
{icon}
{children}
</span>
{count != null && (
<span
data-slot="nav-item-count"
className={cn(
// The chip recipe is tabs.tsx's, verbatim: active gets a
// solid accent FILL, not an accent number (accent-on-accent
// fails contrast), and the fill tracks the LABEL's accent
// step per mode so the count is never the dimmer of the
// two. tabular-nums so a live count doesn't jitter.
// `transition-colors` (background included) is safe HERE:
// the chip paints above the pill inside the positioned
// link, so its fill can cross-fade without muddying the
// indicator. 150ms keeps it paired with the label.
"inline-flex items-center justify-center gap-1 rounded-full font-medium tabular-nums transition-colors duration-150 [&_svg]:shrink-0",
COUNT_SIZE[size],
active
? "bg-accent text-accent-foreground dark:bg-accent-400 dark:text-accent-950"
: "bg-neutral-500/10 dark:bg-neutral-500/15 text-muted-foreground"
)}
>
{count}
</span>
)}
</>
),
...props,
},
})}
</li>
)
}
export { Nav, NavList, NavSection, NavLabel, NavItem }type ActiveHrefResolver = (pathname: string, hrefs: string[]) => string | null
// THE ALIAS ABOVE IS LOAD-BEARING. Do not fold it into the signature below.
//
// The shadcn CLI reprints every file it installs, and everything above the
// file's FIRST STATEMENT is dropped on the way: plain comments and JSDoc
// alike, the whole run of it. This module used to open with the warning below
// and the resolver's own JSDoc, and a consumer received neither, on every
// pull. One real statement in front of them is what makes them survive, and it
// may as well be a useful one: this is the resolver's shape, for a consumer
// threading it through props or a config object. `verify:registry` fails the
// build if a comment ever leads this file again.
//
// NO "use client" HERE, ON PURPOSE.
//
// `nav.tsx` is a client module (Motion, layout effects, context), and under
// React Server Components EVERY export of a client module becomes a client
// reference: renderable, passable as a prop, never CALLED on the server. This
// resolver is meant to be called while rendering, and an App Router page is a
// Server Component by default, so declaring it inside `nav.tsx` would hard-fail
// the production build the moment a server page used it:
//
// Attempted to call resolveActiveHref() from the server but
// resolveActiveHref is on the client.
//
// Re-exporting it from `nav.tsx` would NOT fix that, because the re-export is
// itself a client reference. It lives here, with no directive, so both graphs
// can import it. Same reason `button-variants.ts` and `site-header-classes.ts`
// exist. There is deliberately no back-compat re-export from `nav.tsx`: this
// export is new, so nothing imports it from there yet, and adding one would
// only hand callers the broken path.
//
// This is a pure function. `Nav` does not read the router itself and must not
// start: the kit is router-agnostic, and `SiteHeader` is explicit that the
// shell never learns what page it is on. The consumer resolves the route and
// hands `active` down.
/**
* Resolves the one nav href that should be marked active for a pathname.
*
* "/" only matches an exact "/". Every other href matches on a path-segment
* boundary (the pathname equals the href, or starts with `${href}/`), so
* "/shop" does not also match "/shopping". When more than one href matches
* (nested sections, e.g. "/shop" and "/shop/vip"), the longest one wins.
* Returns null when nothing matches.
*
* `Nav` requires that EXACTLY ONE item carry `active`. Its pill is a single
* sliding element that takes the first `[data-active]` it finds, while every
* active item paints its own accent colour, so two matches put the pill on one
* item and the colour on two. This returns at most one, always.
*
* Usage: resolve once, then compare each item against the result.
*
* const activeHref = resolveActiveHref(pathname, items.map((i) => i.href))
* items.map((i) => <NavItem active={i.href === activeHref} ... />)
*
* Hrefs need not be the link targets. When a section's link points at a page
* inside it, pass the section prefixes and compare against those instead.
*/
function resolveActiveHref(
pathname: string,
hrefs: string[]
): string | null {
let best: string | null = null
for (const href of hrefs) {
const matches =
href === "/"
? // 1. "/" is EXACT-ONLY. Prefix-matching root makes it active on
// every page in the site.
pathname === "/"
: // 2. SEGMENT-BOUNDARY matching, never a bare startsWith(href):
// "/shop" must not match "/shopping". This also means an external
// href ("https://...") can never match, with no branch for it.
pathname === href || pathname.startsWith(`${href}/`)
// 3. LONGEST WINS, so nested sections resolve to the innermost one and
// the answer is a single value rather than a set.
if (matches && (best === null || href.length > best.length)) {
best = href
}
}
return best
}
export { resolveActiveHref }
export type { ActiveHrefResolver }