TabsNavigation

Navigate between sections or filtered views without tab-panel semantics

TabsNavigation shares its visual styles with Tabs, but the two components have different semantic contracts. Use Tabs when each trigger reveals one corresponding content panel. Use TabsNavigation for page sections or filters that update an independent view.

Installation

pnpm add @wandercom/design-system-web

Usage

Use TabsNavigation when controls move to sections on the current page or update a filtered view without replacing an associated content panel.

26 lines
import { TabsNavigation } from '@wandercom/design-system-web/ui/tabs-navigation';
import { useState } from 'react';

export function PropertyTabsNavigation() {
  const [activeSection, setActiveSection] = useState("photos");

  return (
    <TabsNavigation
      aria-label="Property sections"
      items={[
        { label: "Photos", value: "photos" },
        { label: "About", value: "about" },
        { label: "Amenities", value: "amenities" },
      ]}
      value={activeSection}
      onValueChange={(value) => {
        setActiveSection(value);
        document.getElementById(value)?.scrollIntoView({
          behavior: "smooth",
        });
      }}
      variant="underline"
      bordered
    />
  );
}

Examples

Loading example...

Choose the right component

BehaviorComponent
Reveal one corresponding panel while hiding anotherTabs
Scroll to or mark a page sectionTabsNavigation
Filter an independent result listTabsNavigation
Choose a form valueRadioGroup or ToggleGroup

Visual appearance does not determine semantics. Tabs uses the WAI-ARIA Tabs keyboard model, where arrow keys move through a single tab stop. TabsNavigation renders ordinary buttons, so every enabled item remains in the normal sequential tab order.

Variants

The default variant uses pill-shaped buttons. The underline variant uses a moving active indicator and supports a full-width bottom border with bordered.

12 lines
<TabsNavigation
  aria-label="Review filters"
  items={[
    { label: "All", value: "all" },
    { label: "Published", value: "published" },
    { label: "Drafts", value: "drafts", disabled: true },
  ]}
  value={filter}
  onValueChange={setFilter}
  variant="default"
  size="sm"
/>

Props

TabsNavigation

aria-label

string
Accessible label describing the navigation landmark.

items

Array<{ label: ReactNode; value: string; disabled?: boolean }>
Navigation items. Labels may contain markup such as a span with a lang attribute.

value

string
Controlled active item value.

onValueChange

(value: string) => void
Called when an enabled item is activated, including the active item.

variant?

'default' | 'underline'
Visual style variant. Defaults to "default".

size?

'sm' | 'md'
Size for the default pill variant. Defaults to "md" and is ignored by the underline variant.

bordered?

boolean
Shows a bottom border behind the item row. Defaults to false.

className?

string
Additional CSS classes for the navigation root.

listRef?

Ref<HTMLDivElement>
Ref for the item row, useful for horizontal scrolling integrations.

classNames?

{ list?: string; item?: string }
Per-slot CSS class overrides for the item row and buttons.

Accessibility

  • Renders a labeled <nav> landmark containing native buttons.
  • Marks only the active item with aria-current="location".
  • Does not emit tablist, tab, or tabpanel roles.
  • Keeps every enabled item in normal DOM-order keyboard navigation; disabled items use the native disabled attribute.
  • Activating the current item calls onValueChange again so scroll and navigation integrations can repeat their action.
  • Uses the design system focus ring and AA-compliant inactive text color in both light and dark themes.
  • The underline transition respects prefers-reduced-motion.
  • Item labels accept ReactNode, so phrases in another language can provide a lang attribute on their own span.

Migrate from the legacy high-level Tabs API

The high-level panel-less Tabs API emits tab semantics without corresponding panels. Replace that usage with TabsNavigation:

16 lines
// Before: legacy panel-less tab semantics
<Tabs
  classNames={{ trigger: "font-medium" }}
  items={items}
  onChange={setFilter}
  value={filter}
/>

// After: navigation and filtering semantics
<TabsNavigation
  aria-label="Reservation filters"
  classNames={{ item: "font-medium" }}
  items={items}
  onValueChange={setFilter}
  value={filter}
/>
Legacy TabsTabsNavigation
onChangeonValueChange
classNames.triggerclassNames.item
No required labelAdd a descriptive aria-label
Compound TabsRoot, TabsList, and TabsTrigger without panelsHigh-level TabsNavigation

Do not migrate compound Tabs that already pair every TabsTrigger with a corresponding TabsContent. That is the correct Tabs pattern.

TabsNavigation