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"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 and label, in both orientations.
Small
Default
Large
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.
Small
Acme
Default
Acme
Large
Acme
Vertical
orientation="vertical" groups items into NavSections with a NavLabel heading, the sidebar pattern.
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 />. |
| 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.
*
* `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",
}
const ITEM_SIZE: Record<
NavSize,
{ shared: string; horizontal: string; vertical: 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",
},
default: {
shared: "gap-2 [&_svg:not([class*='size-'])]:size-3.5",
horizontal: "h-8 px-2.5",
vertical: "px-2.5 py-1 pointer-coarse:py-2.5",
},
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",
},
}
// 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-2.5 text-1xs",
lg: "px-3 text-xs",
}
const NavContext = createContext<{
orientation: NavOrientation
size: NavSize
pillReady: boolean
}>({ orientation: "horizontal", size: "default", pillReady: false })
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-900",
// 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-6 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-2 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,
active,
className,
render,
children,
...props
}: useRender.ComponentProps<"a"> & {
icon?: 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,
orientation === "horizontal"
? ITEM_SIZE[size].horizontal
: cn("w-full", ITEM_SIZE[size].vertical),
active
? cn("text-accent", !pillReady && "bg-accent-100 dark:bg-accent-900")
: "text-muted-foreground hover:text-foreground hover:bg-neutral-500/10",
className
),
children: (
<>
{icon}
{children}
</>
),
...props,
},
})}
</li>
)
}
export { Nav, NavList, NavSection, NavLabel, NavItem }