Skip to main content

Tabs

Tabs are built on top of Spar primitives. Use Tabs.List for the tablist, Tabs.Trigger for each selectable tab, and Tabs.Content for each matching panel.

Usage​

import { Tabs } from '@takeoff-ui/react-spar';
<Tabs>
<Tabs.List>
<Tabs.Trigger />
</Tabs.List>
<Tabs.Content />
</Tabs>

Playground​


function PlaygroundDemo() {
  return (
    <Tabs defaultValue="overview" className="w-120">
      <Tabs.List aria-label="Booking details">
        <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
        <Tabs.Trigger value="passengers">Passengers</Tabs.Trigger>
        <Tabs.Trigger value="payments">Payments</Tabs.Trigger>
      </Tabs.List>
      <Tabs.Content value="overview">
        <div className="grid gap-1 p-4">
          <strong className="text-text-darkest">Istanbul to London</strong>
          <span className="text-text-base">TK1985 · 08:30 departure · Airbus A321</span>
        </div>
      </Tabs.Content>
      <Tabs.Content value="passengers">
        <div className="grid gap-1 p-4">
          <strong className="text-text-darkest">2 passengers</strong>
          <span className="text-text-base">Seats 12A and 12B, meal preferences saved.</span>
        </div>
      </Tabs.Content>
      <Tabs.Content value="payments">
        <div className="grid gap-1 p-4">
          <strong className="text-text-darkest">Paid</strong>
          <span className="text-text-base">Card ending 2046 · receipt sent to billing contact.</span>
        </div>
      </Tabs.Content>
    </Tabs>
  );
}

render(<PlaygroundDemo />);

Sizes​

Use size on the root to scale all triggers in the set.


function SizesDemo() {
  return (
    <div className="grid gap-6">
      <Tabs size="small" defaultValue="one" className="w-120">
        <Tabs.List aria-label="Small tabs">
          <Tabs.Trigger value="one">One-way</Tabs.Trigger>
          <Tabs.Trigger value="round">Round trip</Tabs.Trigger>
          <Tabs.Trigger value="multi">Multi-city</Tabs.Trigger>
        </Tabs.List>
      </Tabs>

      <Tabs defaultValue="one" className="w-120">
        <Tabs.List aria-label="Base tabs">
          <Tabs.Trigger value="one">One-way</Tabs.Trigger>
          <Tabs.Trigger value="round">Round trip</Tabs.Trigger>
          <Tabs.Trigger value="multi">Multi-city</Tabs.Trigger>
        </Tabs.List>
      </Tabs>

      <Tabs size="large" defaultValue="one" className="w-120">
        <Tabs.List aria-label="Large tabs">
          <Tabs.Trigger value="one">One-way</Tabs.Trigger>
          <Tabs.Trigger value="round">Round trip</Tabs.Trigger>
          <Tabs.Trigger value="multi">Multi-city</Tabs.Trigger>
        </Tabs.List>
      </Tabs>
    </div>
  );
}

render(<SizesDemo />);

Orientation​

Use orientation="vertical" for side navigation or settings panels where the labels need more room.


function VerticalDemo() {
  return (
    <Tabs orientation="vertical" defaultValue="fare" className="w-120">
      <Tabs.List aria-label="Fare details" className="w-40 shrink-0">
        <Tabs.Trigger value="fare">Fare</Tabs.Trigger>
        <Tabs.Trigger value="baggage">Baggage</Tabs.Trigger>
        <Tabs.Trigger value="rules">Rules</Tabs.Trigger>
      </Tabs.List>
      <Tabs.Content value="fare">
        <div className="p-4">
          Flexible economy fare with same-day change support.
        </div>
      </Tabs.Content>
      <Tabs.Content value="baggage">
        <div className="p-4">
          Includes one cabin bag and one checked bag up to 23 kg.
        </div>
      </Tabs.Content>
      <Tabs.Content value="rules">
        <div className="p-4">
          Refundable before departure; fare difference may apply.
        </div>
      </Tabs.Content>
    </Tabs>
  );
}

render(<VerticalDemo />);

Controlled State​

Use value and onValueChange when another part of the interface needs to react to the current tab.


function ControlledTabsDemo() {
  const [value, setValue] = useState('scheduled');

  return (
    <div className="grid gap-3">
      <Badge className="w-fit" variant={value === 'delayed' ? 'warning' : 'success'} appearance="outlined">
        Status: {value}
      </Badge>
      <Tabs value={value} onValueChange={setValue} className="w-120">
        <Tabs.List aria-label="Flight status">
          <Tabs.Trigger value="scheduled">Scheduled</Tabs.Trigger>
          <Tabs.Trigger value="boarding">Boarding</Tabs.Trigger>
          <Tabs.Trigger value="delayed">Delayed</Tabs.Trigger>
        </Tabs.List>
      </Tabs>
    </div>
  );
}

render(<ControlledTabsDemo />);

Appearance​

Use appearance to switch between basic, compact, divided, and expanded.


function AppearanceDemo() {
  return (
    <div className="grid justify-items-center gap-6">
      <Tabs appearance="basic" defaultValue="flights">
        <Tabs.List aria-label="Basic tabs">
          <Tabs.Trigger value="flights">Flights</Tabs.Trigger>
          <Tabs.Trigger value="hotels">Hotels</Tabs.Trigger>
          <Tabs.Trigger value="cars">Cars</Tabs.Trigger>
        </Tabs.List>
      </Tabs>

      <Tabs appearance="compact" defaultValue="flights">
        <Tabs.List aria-label="Compact tabs">
          <Tabs.Trigger value="flights">Flights</Tabs.Trigger>
          <Tabs.Trigger value="hotels">Hotels</Tabs.Trigger>
          <Tabs.Trigger value="cars">Cars</Tabs.Trigger>
        </Tabs.List>
      </Tabs>

      <Tabs appearance="divided" defaultValue="flights">
        <Tabs.List aria-label="Divided tabs">
          <Tabs.Trigger value="flights">Flights</Tabs.Trigger>
          <Tabs.Trigger value="hotels">Hotels</Tabs.Trigger>
          <Tabs.Trigger value="cars">Cars</Tabs.Trigger>
        </Tabs.List>
      </Tabs>

      <Tabs appearance="expanded" defaultValue="flights">
        <Tabs.List aria-label="Expanded tabs">
          <Tabs.Trigger value="flights">Flights</Tabs.Trigger>
          <Tabs.Trigger value="hotels">Hotels</Tabs.Trigger>
          <Tabs.Trigger value="cars">Cars</Tabs.Trigger>
        </Tabs.List>
      </Tabs>
    </div>
  );
}

render(<AppearanceDemo />);

Variant​

Use variant to change the active tab color treatment between primary, info, and neutral.


function VariantDemo() {
  return (
    <div className="grid gap-6">
      <Tabs variant="primary" defaultValue="overview">
        <Tabs.List aria-label="Primary tabs">
          <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
          <Tabs.Trigger value="pricing">Pricing</Tabs.Trigger>
          <Tabs.Trigger value="rules">Rules</Tabs.Trigger>
        </Tabs.List>
      </Tabs>

      <Tabs variant="info" defaultValue="overview">
        <Tabs.List aria-label="Info tabs">
          <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
          <Tabs.Trigger value="pricing">Pricing</Tabs.Trigger>
          <Tabs.Trigger value="rules">Rules</Tabs.Trigger>
        </Tabs.List>
      </Tabs>

      <Tabs variant="neutral" defaultValue="overview">
        <Tabs.List aria-label="Neutral tabs">
          <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
          <Tabs.Trigger value="pricing">Pricing</Tabs.Trigger>
          <Tabs.Trigger value="rules">Rules</Tabs.Trigger>
        </Tabs.List>
      </Tabs>
    </div>
  );
}

render(<VariantDemo />);

Render Props​

Tabs.Trigger children can be a function receiving isSelected, disabled, isFocused, orientation, and select.


function RenderPropDemo() {
  return (
    <Tabs defaultValue="mobile">
      <Tabs.List aria-label="Check-in channel">
        <Tabs.Trigger value="mobile">
          {({ isSelected }) => (isSelected ? 'Mobile selected' : 'Mobile')}
        </Tabs.Trigger>
        <Tabs.Trigger value="counter">
          {({ isSelected }) => (isSelected ? 'Counter selected' : 'Counter')}
        </Tabs.Trigger>
        <Tabs.Trigger value="kiosk" disabled>
          Kiosk closed
        </Tabs.Trigger>
      </Tabs.List>
      <Tabs.Content value="mobile">Mobile check-in opens 24 hours before departure.</Tabs.Content>
      <Tabs.Content value="counter">Airport counters open 3 hours before departure.</Tabs.Content>
    </Tabs>
  );
}

render(<RenderPropDemo />);

Closable Triggers​

Render the close affordance inside Tabs.Trigger children and stop the click from bubbling when you remove the tab from your own state.


function ClosableDemo() {
  const [tabs, setTabs] = useState(['outbound', 'return', 'extras']);
  const [active, setActive] = useState('outbound');
  const labels = {
    outbound: 'Outbound',
    return: 'Return',
    extras: 'Extras',
  };

  const closeTab = (value) => {
    const nextTabs = tabs.filter((item) => item !== value);
    setTabs(nextTabs);
    if (active !== value) {
      return;
    }

    if (nextTabs.length === 0) {
      setActive(undefined);
      return;
    }

    setActive(nextTabs[Math.max(0, tabs.indexOf(value) - 1)]);
  };

  const getCloseClasses = (isSelected) =>
    'inline-flex h-4 w-4 items-center justify-center';

  const handleCloseKeyDown = (event, value) => {
    if (event.key !== 'Enter' && event.key !== ' ') {
      return;
    }

    event.preventDefault();
    event.stopPropagation();
    closeTab(value);
  };

  return (
    <Tabs value={active} onValueChange={setActive} variant="info">
      <Tabs.List aria-label="Closable itinerary tabs">
        {tabs.map((tab) => (
          <Tabs.Trigger key={tab} value={tab}>
            {({ isSelected }) => (
              <span className="inline-flex items-center gap-2">
                <span>{labels[tab]}</span>
                <span
                  role="button"
                  tabIndex={0}
                  aria-label={'Close ' + tab}
                  className={getCloseClasses(isSelected)}
                  onClick={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    closeTab(tab);
                  }}
                  onKeyDown={(event) => handleCloseKeyDown(event, tab)}
                >
                  <CloseIconOutlinedRounded />
                </span>
              </span>
            )}
          </Tabs.Trigger>
        ))}
      </Tabs.List>
      {tabs.includes('outbound') && <Tabs.Content value="outbound">Outbound segment review.</Tabs.Content>}
      {tabs.includes('return') && <Tabs.Content value="return">Return segment review.</Tabs.Content>}
      {tabs.includes('extras') && <Tabs.Content value="extras">Ancillary services and seat add-ons.</Tabs.Content>}
    </Tabs>
  );
}

render(<ClosableDemo />);

Accessibility & Keyboard​

  • Tabs.List renders role="tablist" and wires aria-orientation.
  • Tabs.Trigger renders a role="tab" button with aria-selected and aria-controls connected to its matching panel.
  • Tabs.Content renders role="tabpanel" and lazy-mounts by default. Use forceMount when inactive panels must stay in the DOM.
KeyBehavior
TabMove focus into the active tab, then into the active panel.
↓ / →Move to the next tab for matching orientation.
↑ / ←Move to the previous tab for matching orientation.
HomeMove to the first enabled tab.
EndMove to the last enabled tab.
Enter/SpaceActivate the focused tab when activationMode="manual".

API Reference​

Tabs​

See Spar Tabs docs for primitive behavior.

Props​

NameTypeDefaultDescription
childrenReact.ReactNode-Tabs.List, Tabs.Trigger, and Tabs.Content elements rendered inside the tabs root.
sizeTabsSize'base'Size scale. Cascades to descendant Tabs.Triggers via context.
variantTabsVariant'primary'Color variant used by the active tab treatment.
appearanceTabsAppearance'basic'Visual tab style.
classNamesPartial<Record<"root", string>>-Per-slot class name overrides.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
classNamestring-Appends custom classes to the root slot of this part.

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for the root slot.
data-sizeAlwaysReflects the resolved size prop so theme recipes can scope size variants.
data-variantAlwaysReflects the resolved variant prop for active-state coloring.
data-typeAlwaysReflects the resolved appearance prop for style variants.
data-orientationAlwaysReflects the resolved orientation (horizontal / vertical). Emitted by Spar.

Tabs.List​

See Spar Tabs docs for primitive behavior.

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for the root slot.
data-sizeAlwaysReflects the resolved root size prop.
data-variantAlwaysReflects the resolved root variant prop.
data-typeAlwaysReflects the resolved root appearance prop.
data-orientationAlwaysReflects the resolved orientation. Emitted by Spar.

Tabs.Trigger​

See Spar Tabs docs for primitive behavior.

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for the root slot.
data-sizeAlwaysReflects the resolved root size prop.
data-variantAlwaysReflects the resolved root variant prop.
data-typeAlwaysReflects the resolved root appearance prop.
data-state="active"When the trigger matches the selected value.Spar selected-state hook.
data-state="inactive"When the trigger does not match the selected value.Spar unselected-state hook.
data-disableddisabled is true.Theme hook for disabled triggers. Emitted by Spar.
data-orientationAlwaysReflects the resolved orientation. Emitted by Spar.

Tabs.Content​

See Spar Tabs docs for primitive behavior.

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for the root slot.
data-state="active"When the content matches the selected value.Spar selected-state hook.
data-state="inactive"forceMount is true and the content is not selected.Spar unselected-state hook.
data-orientationAlwaysReflects the resolved orientation. Emitted by Spar.

Type Definitions​

NameDefinition
TabsSize'small' | 'base' | 'large'
TabsVariant'primary' | 'info' | 'neutral'
TabsAppearance'basic' | 'compact' | 'divided' | 'expanded'
TabsTriggerRenderPropsPick<SparTabsTriggerRenderProps, 'isSelected' | 'select' | 'disabled' | 'isFocused' | 'orientation'>