Tabs

Tab set with roving focus and an ink underline active state.

organismsSource
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@elirobinson/react/components/organisms/Tabs';

Styles: @elirobinson/react/styles/organisms/Tabs.css — already included when you import @elirobinson/react/styles.css.

Youth Football Fundamentals covers eight sessions of ball control, positioning, and small-sided games for ages 8-12.

Show code
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from '@elirobinson/react/components/organisms/Tabs';

export default function Basic() {
  return (
    <Tabs defaultValue="overview">
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="curriculum">Curriculum</TabsTrigger>
        <TabsTrigger value="reviews">Reviews</TabsTrigger>
      </TabsList>
      <TabsContent value="overview">
        <p>
          Youth Football Fundamentals covers eight sessions of ball control, positioning, and
          small-sided games for ages 8-12.
        </p>
      </TabsContent>
      <TabsContent value="curriculum">
        <p>Session plans, warm-up drills, and printable session cards — one PDF per week.</p>
      </TabsContent>
      <TabsContent value="reviews">
        <p>
          &quot;My under-10s squad picked up the passing drills in one session.&quot; — Coach Priya
          Nair
        </p>
      </TabsContent>
    </Tabs>
  );
}

When to use it

Use Tabs to switch between views that share the same context — different cuts of one dataset, sections of one guide, steps in a self-contained flow. Inactive panels don't just hide: TabsContent returns null when it isn't the active tab, so anything with local state inside it unmounts and resets when you navigate away and back.

Reach for Accordion instead when the sections aren't mutually exclusive — a reader might want two open at once. Reach for a real page or route once the content is substantial enough that someone would want to bookmark or share a link straight to one section.

Controlled

Pass value and onValueChange when something outside the tablist needs to read or drive the active tab — here, external Back/Next buttons step through the same tabs. defaultValue is still required by the type even in controlled mode; pass the same initial value you gave your own state.

Guide: Basketball Conditioning — digital PDF, delivered by email.

Show code
import { useState } from 'react';
import { Button } from '@elirobinson/react/components/atoms/Button';
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from '@elirobinson/react/components/organisms/Tabs';

const STEPS = ['details', 'shipping', 'confirm'] as const;
type Step = (typeof STEPS)[number];

export default function Controlled() {
  const [step, setStep] = useState<Step>('details');
  const index = STEPS.indexOf(step);

  return (
    <div className="demo-col">
      <Tabs defaultValue="details" value={step} onValueChange={(value) => setStep(value as Step)}>
        <TabsList>
          <TabsTrigger value="details">Details</TabsTrigger>
          <TabsTrigger value="shipping">Shipping</TabsTrigger>
          <TabsTrigger value="confirm">Confirm</TabsTrigger>
        </TabsList>
        <TabsContent value="details">
          <p>Guide: Basketball Conditioning — digital PDF, delivered by email.</p>
        </TabsContent>
        <TabsContent value="shipping">
          <p>Nothing to ship — digital guides land in your inbox within a minute.</p>
        </TabsContent>
        <TabsContent value="confirm">
          <p>Review your order, then check out below.</p>
        </TabsContent>
      </Tabs>
      <div className="demo-row">
        <Button
          variant="secondary"
          disabled={index === 0}
          onClick={() => setStep(STEPS[index - 1])}
        >
          Back
        </Button>
        <Button
          variant="primary"
          disabled={index === STEPS.length - 1}
          onClick={() => setStep(STEPS[index + 1])}
        >
          Next
        </Button>
      </div>
    </div>
  );
}

Disabled tab

A disabled TabsTrigger can't take focus, so arrow-key navigation skips over it — and if it happens to be the active tab, the tablist's single tab stop moves to the first focusable trigger instead.

Youth Football Fundamentals — 8 sessions, ages 8-12.

Show code
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from '@elirobinson/react/components/organisms/Tabs';

export default function Disabled() {
  return (
    <Tabs defaultValue="football">
      <TabsList>
        <TabsTrigger value="football">Football</TabsTrigger>
        <TabsTrigger value="basketball" disabled>
          Basketball (sold out)
        </TabsTrigger>
        <TabsTrigger value="rugby">Rugby</TabsTrigger>
      </TabsList>
      <TabsContent value="football">
        <p>Youth Football Fundamentals — 8 sessions, ages 8-12.</p>
      </TabsContent>
      <TabsContent value="basketball">
        <p>Basketball Conditioning is fully booked for this term.</p>
      </TabsContent>
      <TabsContent value="rugby">
        <p>Rugby Contact Basics — 6 sessions, ages 10-14.</p>
      </TabsContent>
    </Tabs>
  );
}

Props

PropTypeDefaultDescription
defaultValuestring | number | readonly string[]
onValueChange((value: string) => void)
valuestring

Also accepts all HTMLAttributes<HTMLDivElement> props.

TabsList

PropTypeDefaultDescription
defaultValuestring | number | readonly string[]

Also accepts all Omit<HTMLAttributes<HTMLDivElement>, 'role'> props.

TabsTrigger

PropTypeDefaultDescription
valuerequiredstring
defaultValuestring | number | readonly string[]
onClickMouseEventHandler<HTMLButtonElement>

Also accepts all Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value' | 'id' | 'role' | 'onClick' | 'aria-selected' | 'aria-controls' | 'tabIndex'> props.

TabsContent

PropTypeDefaultDescription
valuerequiredstring
defaultValuestring | number | readonly string[]

Also accepts all Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'role' | 'aria-labelledby'> props.

Accessibility

Implements the WAI-ARIA tabs pattern: role="tablist" on TabsList, role="tab" on each TabsTrigger (with aria-selected and aria-controls), role="tabpanel" on TabsContent (with aria-labelledby pointing back at its tab).

  • Roving tabindex: exactly one trigger is in the page's tab order at a time — normally the active one — everything else carries tabIndex={-1}.
  • Keyboard: Tab moves focus into and out of the tablist. ArrowRight/ArrowDown move to the next trigger, ArrowLeft/ArrowUp to the previous, both wrapping past the ends. Home jumps to the first trigger, End to the last. Arrow keys only move focus — they do not select. Enter and Space activate whichever trigger currently has focus.
  • Disabled triggers are skipped entirely by arrow navigation and can never hold the tab stop; if the active trigger is disabled, ownership falls back to the first focusable one.
  • Two independent Tabs instances on the same page keep separate tablists — arrow navigation never crosses from one into the other.

Do

  • Match TabsTrigger and TabsContent value props exactly — that pairing wires aria-controls and aria-labelledby together.
  • Use value and onValueChange when something outside the tablist needs to read or drive the active tab.
  • Keep trigger labels to a word or two — TabsList wraps once labels get long.
  • Give a disabled tab a reason in its own label ("Basketball (sold out)") since arrow navigation will silently skip past it.

Don't

  • Expect a custom onClick's preventDefault() to block the tab switch — TabsTrigger always calls setActiveTab after your handler runs.
  • Disable every trigger in a TabsList — with nothing focusable left, the whole tablist drops out of the tab order.
  • Keep unsaved form input inside a TabsContent panel and expect it to survive a trip to another tab — panels unmount, they do not hide.
  • Test the keyboard contract with clicks only — the real contract is roving focus (arrows, Home, End) plus Enter/Space on the focused trigger.