Skip to contentVibraUI
Navigation & layout

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-tabs

Needs the @vibra registry in your components.json — set it up once.

Examples

Pills

The free-standing variant at both sizes, for a row that has no border to sit on.

Props

PropTypeDefaultDescription
itemsNavTabItem[]—The tabs, in order; each needs a label and an href.
activeHrefstring—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.ReactNodea plain anchorSwaps the anchor for a router link; it must set aria-current itself.
NavTabItem.labelReact.ReactNode—The tab's text.
NavTabItem.hrefstring—Where the tab goes.
NavTabItem.countnumber—A number beside the label — open tickets, failed rows, unread alerts.
NavTabItem.iconReact.ReactNode—A decorative icon before the label, rendered at size 4.
NavTabItem.disabledbooleanfalseRenders the tab as dimmed text with no link and no tab stop.

Dependencies

Source

components/ui/nav-tabs.tsx
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 }