Nav tabs
A row of links that reads as tabs, underlined or as pills, with counts and disabled states.
Server-compatible: no hooks, no client boundary. These are links, not tab panels — one page per tab, and the URL is the state; use the tabs primitive for panels that switch in place. The active link is marked aria-current="page"; a custom renderLink owns that attribute on its own element. It is a nav landmark, so give it an aria-label when a page has more than one. Pass it to PageHeader's tabs slot and the underline lands on the header's own border.
Install
$
npx shadcn@latest add @vibra/nav-tabsNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { NavTabs } from "@/components/ui/nav-tabs"
const ITEMS = [
{ label: "Overview", href: "/reports" },
{ label: "Analytics", href: "/reports/analytics", count: 3 },
{ label: "Customers", href: "/reports/customers" },
{ label: "Settings", href: "/reports/settings" },
]
export default function NavTabsDemo() {
const [active, setActive] = React.useState("/reports/analytics")
return (
<div className="w-full">
<NavTabs
aria-label="Report views"
items={ITEMS}
activeHref={active}
renderLink={(item, children, className) => (
<a
href={item.href}
className={className}
aria-current={item.href === active ? "page" : undefined}
onClick={(event) => {
event.preventDefault()
setActive(item.href)
}}
>
{children}
</a>
)}
/>
<p className="pt-4 text-sm text-muted-foreground">
Showing {ITEMS.find((item) => item.href === active)?.label}.
</p>
</div>
)
}Pills
The free-standing variant at both sizes, for a row that has no border to sit on.
import { NavTabs } from "@/components/ui/nav-tabs"
const ITEMS = [
{ label: "Overview", href: "/reports" },
{ label: "Analytics", href: "/reports/analytics", count: 3 },
{ label: "Customers", href: "/reports/customers" },
]
export default function NavTabsPills() {
return (
<div className="flex w-full flex-col gap-6">
<NavTabs
aria-label="Report views"
variant="pills"
items={ITEMS}
activeHref="/reports/analytics"
/>
<NavTabs
aria-label="Report views, compact"
variant="pills"
size="sm"
items={ITEMS}
activeHref="/reports"
/>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | NavTabItem[] | — | The tabs, in order; each needs a label and an href. |
| activeHref | string | — | The href of the current page; that item is marked as current. |
| size | "sm" | "default" | "default" | sm drops the label to text-xs and tightens the row. |
| variant | "underline" | "pills" | "underline" | underline rides a hairline under the row; pills is a free-standing group. |
| renderLink | (item: NavTabItem, children: React.ReactNode, className: string) => React.ReactNode | a plain anchor | Swaps the anchor for a router link; it must set aria-current itself. |
| NavTabItem.label | React.ReactNode | — | The tab's text. |
| NavTabItem.href | string | — | Where the tab goes. |
| NavTabItem.count | number | — | A number beside the label — open tickets, failed rows, unread alerts. |
| NavTabItem.icon | React.ReactNode | — | A decorative icon before the label, rendered at size 4. |
| NavTabItem.disabled | boolean | false | Renders the tab as dimmed text with no link and no tab stop. |
Dependencies
Registry
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const navTabsVariants = cva("flex items-center", {
variants: {
variant: {
// The row's own rule — the same weight the page header underlines with,
// so a NavTabs handed to PageHeader's `tabs` slot lands its 2px accent
// exactly on the header's line rather than a hair above it.
underline: "gap-4 border-b border-rule",
pills: "gap-1",
},
},
defaultVariants: { variant: "underline" },
})
const navTabsItemVariants = cva(
"inline-flex items-center gap-2 font-medium whitespace-nowrap transition-[color,border-color] duration-(--duration-fast) ease-(--ease-standard) focus-ring aria-disabled:pointer-events-none aria-disabled:opacity-50",
{
variants: {
variant: {
underline: "-mb-px rounded-none border-b-2 border-transparent px-1",
pills: "rounded-md",
},
size: { default: "type-label", sm: "text-2xs" },
active: { true: "", false: "text-muted-foreground hover:text-foreground" },
},
compoundVariants: [
{ variant: "underline", active: true, className: "border-brand text-foreground" },
{ variant: "underline", size: "default", className: "py-2.5" },
{ variant: "underline", size: "sm", className: "py-2" },
{ variant: "pills", active: true, className: "bg-card shadow-(--shadow-sheet) text-foreground" },
{ variant: "pills", active: false, className: "hover:bg-muted/60" },
{ variant: "pills", size: "default", className: "h-8 px-3" },
{ variant: "pills", size: "sm", className: "h-7 px-2.5" },
],
defaultVariants: { variant: "underline", size: "default", active: false },
}
)
export type NavTabItem = {
label: React.ReactNode
href: string
/** A count beside the label — open tickets, failed rows, unread alerts. */
count?: number
icon?: React.ReactNode
disabled?: boolean
}
export type NavTabsProps = React.ComponentProps<"nav"> & {
items: NavTabItem[]
activeHref?: string
size?: NonNullable<VariantProps<typeof navTabsItemVariants>["size"]>
variant?: NonNullable<VariantProps<typeof navTabsVariants>["variant"]>
/** Swaps the plain anchor for a router link; it owns aria-current on its own element. */
renderLink?: (item: NavTabItem, children: React.ReactNode, className: string) => React.ReactNode
}
/** A row of links that reads as tabs — one page per tab, and the URL is the state. */
function NavTabs({
className,
items,
activeHref,
size = "default",
variant = "underline",
renderLink,
...props
}: NavTabsProps) {
return (
<nav
data-slot="nav-tabs"
data-variant={variant}
data-size={size}
className={cn(navTabsVariants({ variant }), className)}
{...props}
>
{items.map((item) => {
const active = item.href === activeHref
const itemClassName = cn(navTabsItemVariants({ variant, size, active }))
const content = (
<>
{item.icon ? (
<span aria-hidden="true" className="[&_svg]:size-4 [&_svg]:shrink-0">
{item.icon}
</span>
) : null}
{item.label}
{/* A separate word in the link's name — "Lessons, 11" — where the
bare figure ran into the label as "Lessons11". */}
{item.count !== undefined ? <span className="sr-only">, </span> : null}
{item.count !== undefined ? (
<span
data-slot="nav-tabs-count"
className="rounded-full bg-muted px-1.5 py-0.5 text-2xs text-muted-foreground tabular-nums"
>
{item.count}
</span>
) : null}
</>
)
if (item.disabled) {
return (
<span
key={item.href}
data-slot="nav-tabs-item"
data-active={active || undefined}
aria-current={active ? "page" : undefined}
aria-disabled="true"
className={itemClassName}
>
{content}
</span>
)
}
if (renderLink) {
return (
<React.Fragment key={item.href}>
{renderLink(item, content, itemClassName)}
</React.Fragment>
)
}
return (
<a
key={item.href}
href={item.href}
data-slot="nav-tabs-item"
data-active={active || undefined}
aria-current={active ? "page" : undefined}
className={itemClassName}
>
{content}
</a>
)
})}
</nav>
)
}
export { NavTabs, navTabsItemVariants, navTabsVariants }