Skip to content
Nizivo UI

TabNav

Links styled as tabs, for views that are URLs (status filters, sub-pages). A navigation landmark of plain links with aria-current="page" on the current one, server-rendered with no JavaScript; a router's link goes in through render. In-page views with panels are Tabs.

Server component, no client JS

Import

import { TabNav } from '@nizivo/react/tab-nav';

// Once per app (Getting started), after
// @nizivo/tokens/tokens.css and @nizivo/react/css/base.css
import '@nizivo/react/css/tab-nav.css';

Static or interactive

Server. It ships no client JavaScript when a Server Component renders it, in every Next.js build mode; its code is bundled only when a client component (a 'use client' file) imports it. Its links are plain <a> elements. A router link passed through render is the router's own code: on the Next.js fixture's /tab-nav page, three links through next/link load no Nizivo module and 3,302 B (gzip, Turbopack) of Next's. It is the static alternative to Tabs when every view is a URL. How to choose.

In a client app (a Vite or React Router single-page app, or a 'use client' file) it is an ordinary React component: event handlers, refs and state work as usual. In a client app.

Examples

Basic

Status filters that are URLs: each one is a link, the current one is aria-current="page". It is server-rendered and needs no JavaScript; Tab moves between the links.

import type { JSX } from 'react';
import { TabNav } from '@nizivo/react/tab-nav';

const STATUSES = [
  ['all', 'All orders'],
  ['unfulfilled', 'Unfulfilled'],
  ['unpaid', 'Unpaid'],
  ['open', 'Open'],
  ['archived', 'Archived'],
] as const;

export function TabNavExample(): JSX.Element {
  const status: string = 'unfulfilled';
  return (
    <TabNav.Root aria-label="Order status">
      {STATUSES.map(([value, label]) => (
        <TabNav.Link key={value} href={`?status=${value}`} current={value === status}>
          {label}
        </TabNav.Link>
      ))}
    </TabNav.Root>
  );
}

Router

A router's link goes in through render; a count follows the label.

import type { ComponentPropsWithRef, JSX } from 'react';
import { TabNav } from '@nizivo/react/tab-nav';

/** Stands in for a router link (Next `Link`, React Router `Link`). */
function Link({ children, ...props }: ComponentPropsWithRef<'a'>): JSX.Element {
  return <a {...props}>{children}</a>;
}

export function TabNavRouterExample(): JSX.Element {
  return (
    <TabNav.Root aria-label="Customers">
      <TabNav.Link render={<Link href="?segment=vip" />} count={24} current>
        VIP
      </TabNav.Link>
      <TabNav.Link render={<Link href="?segment=all" />} count="1,280">
        All
      </TabNav.Link>
      <TabNav.Link render={<Link href="?segment=subscribers" />} count={311}>
        Subscribers
      </TabNav.Link>
      <TabNav.Link render={<Link href="?segment=at-risk" />} count={9}>
        At risk
      </TabNav.Link>
    </TabNav.Root>
  );
}

Parts

Use the parts through the namespace (TabNav.Root) or by their flat names (TabNavRoot); both work from Server Components.

TabNav.Root

Server component Renders data-slot="tab-nav" with the class nz-tab-nav. Props: TabNavRootProps. Source: src/tab-nav/tab-nav.tsx.

NameTypeDefaultDescription
aria-labelstring–

Names the landmark ("Order status"). A page with more than one navigation landmark needs a name on each.

renderRenderProp<Record<string, never>>–

Must render a navigation landmark (a <nav>, or an element with role="navigation").

  • Also takes every attribute of <nav>; aria-label is redeclared above. (ComponentPropsWithRef<"nav">)

render takes an element, or, in client code, a function of the props and no state.

Server component Renders data-slot="tab-nav-link" with the class nz-tab-nav-link. Props: TabNavLinkProps. Source: src/tab-nav/tab-nav.tsx.

NameTypeDefaultDescription
childrenrequiredReactNode–
countReactNode–

A count after the label, such as the number of orders in a status.

currentbooleanfalse

The link to the page being shown: sets aria-current="page" and draws the indicator. The page knows its own route, so it says which link is current (a router's usePathname needs a client component; a server page reads its params).

iconReactNode–

A decorative glyph before the label (16 px).

renderRenderProp<TabNavLinkState>–

The router's link, for example render={<Link href="/orders?status=open" />} (Next.js, React Router, TanStack Router, Astro's plain <a>). It must render an <a>.

  • Also takes every attribute of <a> except children. (Omit<ComponentPropsWithRef<"a">, "children">)

render takes an element, or, in client code, a function of the props and this state (TabNavLinkState):

NameTypeDefaultDescription
currentrequiredboolean–

Other exports

TabNavLinkProps type

import type { TabNavLinkProps } from '@nizivo/react/tab-nav'

The props of TabNav.Link, listed above.

TabNavLinkState type

import type { TabNavLinkState } from '@nizivo/react/tab-nav'

The render state of TabNav.Link, listed above.

TabNavRootProps type

import type { TabNavRootProps } from '@nizivo/react/tab-nav'

The props of TabNav.Root, listed above.

Data attributes and slots

Style parts by class (.nz-<slot>) and state by data attribute. Attribute meanings come from contracts/vocabulary.json.

SlotRendered byData attributes
tab-nav
.nz-tab-nav
TabNav.Root
root (takes render)
none
tab-nav-count
.nz-tab-nav-count
TabNav.Link
inner element (CSS only)
none
tab-nav-icon
.nz-tab-nav-icon
TabNav.Link
inner element (CSS only)
none
tab-nav-label
.nz-tab-nav-label
TabNav.Link
inner element (CSS only)
none
tab-nav-link
.nz-tab-nav-link
TabNav.Link
root (takes render); interactive, focus: self; forced colours: fill on [aria-current='page']::before
  • data-focus-inset (present or absent)
    a11y-floor opt-out: draw the focus outline inside the part (outline-offset -2px), for clipped scrollers such as DialogBody and table cells (§8.5)

CSS hooks

Set a hook on the component's class or any ancestor to restyle it without specificity games; leave it unset for the default shown.

HookDefaultRead as
--nz-tab-nav-border--nz-border-subtle
  • border-block-end on .nz-tab-nav
--nz-tab-nav-count-bg--nz-bg-subtle
  • background on .nz-tab-nav-count
--nz-tab-nav-fg--nz-text-secondary
  • color on .nz-tab-nav-link
--nz-tab-nav-fg-current--nz-text-primary
  • color on .nz-tab-nav-link[aria-current="page"]
--nz-tab-nav-indicator--nz-bg-brand
  • background on .nz-tab-nav-link[aria-current="page"]:before

Layers: nz.components. Classes: .nz-tab-nav, .nz-tab-nav-count, .nz-tab-nav-icon, .nz-tab-nav-label, .nz-tab-nav-link, .nz-tab-nav-sizer, .nz-tab-nav-text.

Tokens this stylesheet reads (23)

--nz-bg-brand, --nz-bg-subtle, --nz-border-strong, --nz-border-subtle, --nz-border-width-default, --nz-border-width-focus, --nz-duration-fast, --nz-easing-standard, --nz-font-label-m, --nz-font-label-m-strong-font-weight, --nz-font-label-s, --nz-radius-full, --nz-size-control-md, --nz-size-icon-sm, --nz-space-0-5, --nz-space-1, --nz-space-1-5, --nz-space-3, --nz-text-primary, --nz-text-secondary, --nz-tracking-label-m, --nz-tracking-label-m-strong, --nz-tracking-label-s

Accessibility

These parts are interactive. Each gets the focus outline and touch-action rule from @nizivo/react/css/base.css (the nz.base layer), so the floor survives a restyle:

  • tab-nav-link: draws the focus outline itself on keyboard focus.

In forced-colours mode these states keep a visible system colour: tab-nav-link[aria-current='page']::before (fill).

Keyboard

KeysAction
Tab, Shift+TabMoves between the links, as on any link.
EnterFollows the focused link.

Source: native <a> elements in a <nav> (tab-nav.tsx, no client code). The browser lane (test/browser/tabs.spec.tsx) checks Tab between links and the focus outline.

Figma

Node ids from design/component-map.json, which nz gen writes from component.json. The links open the Nizivo Figma library, which may ask you to sign in.

  • Canonical: none
  • Ghost masters: none

On this page