Skip to main content

TabNav

Moleculebeta@macaw-ds/ui · since 0.1.0

Page-level tabs where each tab is its own URL. Not for switching panels in place; that is Tabs (not in the system yet).

Import

import { TabNav, TabNavItem } from '@macaw-ds/ui';

Props

PropTypeDefaultDescription
aria-label*stringPage sections

Names the navigation landmark, e.g. "Invoice sections".

Accessibility: Required: pages often have several nav landmarks.

activebooleanfalse

On TabNav.Item: the current page.

Accessibility: Sets aria-current="page".

asChildbooleanfalse

On TabNav.Item: render a router Link instead of an anchor.

childrenReactNode—

TabNav.Item elements.

Slots

Named parts you pass content into.

  • Item

Tokens used

Every value this component renders comes from one of these tokens.

TokenLightDark
color.action.primary

semantic · color

oklch(0.51 0.2 262)
oklch(0.51 0.2 262)
color.fg.primary

semantic · color

oklch(0.15 0.012 250)
oklch(0.985 0.002 250)
color.fg.secondary

semantic · color

oklch(0.38 0.012 250)
oklch(0.87 0.007 250)
color.line.strong

semantic · color

oklch(0.53 0.012 250)
oklch(0.53 0.012 250)
size.control.lg

semantic · dimension

40px
40px
text.size.label-md

semantic · dimension

0.875rem
0.875rem
color.focus.ring

semantic · color

oklch(0.51 0.2 262)
oklch(0.68 0.16 262)

Eject

Ejecting copies the source into your repo and records it in .macaw/ejected.json, so the fork stays visible. The same file is served as a shadcn-compatible registry item.

With the MACAW CLI
npx @macaw-ds/cli add tab-nav --eject
With shadcn
npx shadcn@latest add https://macaw-design-system.vercel.app/r/tab-nav.json

Bundle budget: 2,000 bytes gzipped, enforced by size-limit in CI.

Manifest

registry/manifests/tab-nav.json, the contract the playground, CLI, plugin and MCP server read.

{
  "name": "TabNav",
  "slug": "tab-nav",
  "package": "@macaw-ds/ui",
  "status": "beta",
  "since": "0.1.0",
  "level": "molecule",
  "category": "navigation",
  "purpose": "Page-level tabs where each tab is its own URL. Not for switching panels in place; that is Tabs (not in the system yet).",
  "description": "Navigation tabs for the sections of a page: Overview, Usage, Accessibility, Code. Each item is a link, so tabs can be bookmarked, shared and opened in a new tab. The current tab sets aria-current=\"page\". Usually rendered by PageHeader's tabs slot.",
  "exports": [
    "TabNav",
    "TabNavItem"
  ],
  "props": {
    "aria-label": {
      "type": "string",
      "default": "Page sections",
      "required": true,
      "description": "Names the navigation landmark, e.g. \"Invoice sections\".",
      "a11y": "Required: pages often have several nav landmarks."
    },
    "active": {
      "type": "boolean",
      "default": false,
      "required": false,
      "description": "On TabNav.Item: the current page.",
      "a11y": "Sets aria-current=\"page\"."
    },
    "asChild": {
      "type": "boolean",
      "default": false,
      "required": false,
      "description": "On TabNav.Item: render a router Link instead of an anchor.",
      "control": "none"
    },
    "children": {
      "type": "node",
      "required": false,
      "description": "TabNav.Item elements.",
      "control": "none"
    }
  },
  "slots": [
    "Item"
  ],
  "tokens": [
    "color.action.primary",
    "color.fg.primary",
    "color.fg.secondary",
    "color.line.strong",
    "size.control.lg",
    "text.size.label-md",
    "color.focus.ring"
  ],
  "a11y": {
    "role": "navigation, containing a list of links",
    "focusable": true,
    "keyboard": [
      "Tab: move between tabs (each is a link)",
      "Enter: follow the link"
    ],
    "name": "The navigation landmark takes its name from aria-label; each tab from its text.",
    "notes": [
      "Navigation tabs are links, not role=\"tab\". Arrow keys are not used; that pattern belongs to in-page Tabs.",
      "The selected tab is marked with aria-current=\"page\", and the underline is not the only signal: the text colour changes too.",
      "The list never scrolls horizontally, so the 2px focus ring is never clipped; keep to six short labels."
    ]
  },
  "motion": {
    "description": "Underline and text colour ease on hover.",
    "tokens": [
      "duration.fast",
      "ease.standard"
    ]
  },
  "budget": {
    "gzip": 2000
  },
  "rules": [
    "Each tab is a URL. Use TabNav only when the sections are separate pages or routes.",
    "Exactly one item is active, and it matches the current route.",
    "Keep labels to one or two words; four to six tabs at most.",
    "With a router, pass the Link through asChild so client-side navigation keeps working."
  ],
  "dos": [
    "Place TabNav in PageHeader's tabs slot so it sits under the page title and sticks below the TopBar."
  ],
  "donts": [
    "Do not use TabNav to swap panels without changing the URL.",
    "Do not nest TabNav inside a Card."
  ],
  "examples": [
    {
      "name": "Default",
      "props": {
        "aria-label": "Page sections"
      }
    }
  ],
  "registryDependencies": [],
  "dependencies": [
    "radix-ui"
  ],
  "related": [
    "page-header",
    "side-nav"
  ]
}