Skip to content
Caseroom

Components · Navigation

Tabs

Panels behind a row of tabs, or page links that look like tabs. Arrow keys, Home and End.

38% of the 2,062 sites in The UI Index have a navigation (781 sites).

Three pages changed since the last deploy.

This week: 23 deploys.

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/tabs.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/tabs.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

tabs.tsx
import { useState, type KeyboardEvent, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { countClasses, panelId, tabId, tabListVariants, tabPanelClasses, tabVariants, type TabItem, type TabsVariants } from './tabs.variants';

export type { TabItem };

export interface TabsProps extends TabsVariants {
  tabs: TabItem[];
  /** Id of the selected tab (the current page in nav mode). Default: the first enabled tab. */
  selected?: string;
  /** The accessible name of the tab list. */
  label: string;
  /** Unique on the page; prefixes the element ids. */
  id: string;
  /** Panel content per tab id (the Astro version's named slots). */
  panels?: Record<string, ReactNode>;
  onTabsChange?: (detail: { id: string }) => void;
  className?: string;
}

/** WAI-ARIA tabs with automatic activation: click, Left/Right (wrapping, disabled skipped), Home/End. */
export function Tabs({ tabs, selected, label, id, variant, panels = {}, onTabsChange, className }: TabsProps) {
  const initial = selected ?? tabs.find((t) => !t.disabled)?.id;
  const [current, setCurrent] = useState(initial);
  const [prev, setPrev] = useState(selected);
  if (selected !== prev) {
    setPrev(selected);
    setCurrent(initial);
  }
  const isNav = tabs.length > 0 && tabs.every((t) => t.href);

  if (isNav)
    return (
      <nav aria-label={label} className={cn('w-full', className)}>
        <ul className={cn(tabListVariants({ variant }), 'm-0 list-none p-0')}>
          {tabs.map((t) => (
            <li key={t.id} className="shrink-0">
              <a href={t.disabled ? undefined : t.href} aria-disabled={t.disabled ? 'true' : undefined} role={t.disabled ? 'link' : undefined} aria-current={t.id === current ? 'page' : undefined} className={tabVariants({ variant })}>
                {t.label}
                {t.count !== undefined && <span className={countClasses}>{t.count}</span>}
              </a>
            </li>
          ))}
        </ul>
      </nav>
    );

  const select = (t: TabItem, focus: boolean) => {
    setCurrent(t.id);
    const el = document.getElementById(tabId(id, t.id));
    if (focus) el?.focus();
    el?.closest('[data-tabs]')?.dispatchEvent(new CustomEvent('tabs-change', { bubbles: true, detail: { id: tabId(id, t.id) } }));
    onTabsChange?.({ id: tabId(id, t.id) });
  };
  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, t: TabItem) => {
    const enabled = tabs.filter((x) => !x.disabled);
    const i = enabled.indexOf(t);
    const next =
      e.key === 'ArrowRight' ? enabled[(i + 1) % enabled.length]
      : e.key === 'ArrowLeft' ? enabled[(i - 1 + enabled.length) % enabled.length]
      : e.key === 'Home' ? enabled[0]
      : e.key === 'End' ? enabled.at(-1)
      : undefined;
    if (!next) return;
    e.preventDefault();
    select(next, true);
  };

  return (
    <div data-tabs="" className={cn('w-full', className)}>
      <div role="tablist" aria-label={label} className={tabListVariants({ variant })}>
        {tabs.map((t) => (
          <button key={t.id} type="button" role="tab" id={tabId(id, t.id)} aria-controls={panelId(id, t.id)} aria-selected={t.id === current ? 'true' : 'false'} tabIndex={t.id === current ? 0 : -1} disabled={t.disabled} className={tabVariants({ variant })} onClick={() => select(t, false)} onKeyDown={(e) => onKeyDown(e, t)}>
            {t.label}
            {t.count !== undefined && <span className={countClasses}>{t.count}</span>}
          </button>
        ))}
      </div>
      {tabs.map((t) => (
        <div key={t.id} role="tabpanel" id={panelId(id, t.id)} aria-labelledby={tabId(id, t.id)} hidden={t.id !== current} tabIndex={0} className={tabPanelClasses}>
          {panels[t.id]}
        </div>
      ))}
    </div>
  );
}
tabs.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';

/** The row of tabs. line: a hairline under the row, the selected tab underlined in ink. pill: a muted track, the
 *  selected tab a raised chip. */
export const tabListVariants = cva('flex max-w-full items-center overflow-x-auto [scrollbar-width:none]', {
  variants: {
    variant: {
      // contain: inline-size keeps the row from widening an auto-sized parent (a grid track would grow to fit every
      // tab); it fills its space and scrolls instead. The edges shade where more tabs are hidden (scrolling shadows:
      // the covers scroll with the tabs, the shades stay; no script). --tabs-surface: the ground under the row; the shade turns light in dark mode.
      line: 'w-full gap-1 border-b [contain:inline-size] [background:linear-gradient(to_right,var(--tabs-surface,var(--background))_50%,transparent)_left/2rem_100%_no-repeat_local,linear-gradient(to_left,var(--tabs-surface,var(--background))_50%,transparent)_right/2rem_100%_no-repeat_local,radial-gradient(farthest-side_at_0_50%,var(--tabs-shade,rgb(0_0_0/0.22)),transparent)_left/0.75rem_100%_no-repeat_scroll,radial-gradient(farthest-side_at_100%_50%,var(--tabs-shade,rgb(0_0_0/0.22)),transparent)_right/0.75rem_100%_no-repeat_scroll]',
      pill: 'inline-flex w-fit gap-1 rounded-lg bg-muted p-1',
    },
  },
  defaultVariants: { variant: 'line' },
});

/** One tab. Selected comes from aria-selected (or aria-current in nav mode), so the state is the DOM's, not a class. */
export const tabVariants = cva(
  'relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[color,background-color,box-shadow] select-none hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
  {
    variants: {
      variant: {
        // The underline grows from the centre when a tab is selected (no movement with reduced motion).
        // The ring sits inside the tab: the row scrolls (overflow-x), which would clip a ring drawn outside it.
        line: 'h-10 px-3 outline-offset-[-6px] after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:scale-x-0 after:rounded-full after:bg-foreground after:transition-transform after:duration-200 hover:bg-accent/60 active:bg-pressed aria-selected:text-foreground aria-selected:after:scale-x-100 aria-[current=page]:text-foreground aria-[current=page]:after:scale-x-100 rounded-md',
        pill: 'h-8 rounded-md px-3 hover:bg-input/45 hover:text-foreground active:scale-[0.97] active:bg-pressed aria-selected:hover:shadow-[0_1px_3px_rgb(0_0_0/0.14),0_0_0_1px_var(--input)] aria-selected:active:bg-background/70 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] aria-[current=page]:bg-background aria-[current=page]:text-foreground',
      },
    },
    defaultVariants: { variant: 'line' },
  },
);

/** A panel. It takes focus (tabindex 0) so its content is one Tab stop away from the list. */
/** A panel. It takes focus (tabindex 0); its content is inset 12px from the ring, with no shift in layout. */
export const tabPanelClasses = 'mt-4 -mx-3 rounded-md px-3';

export type TabsVariants = VariantProps<typeof tabListVariants>;

export interface TabItem {
  /** Unique within the tabs; the panel's slot name (Astro) or key in `panels` (React). */
  id: string;
  label: string;
  /** A count after the label, muted. */
  count?: number;
  disabled?: boolean;
  /** Makes the tab a page link. When every tab has an href the tabs render as a <nav> with aria-current. */
  href?: string;
}

export const tabId = (id: string, tab: string) => `${id}-tab-${tab}`;
export const panelId = (id: string, tab: string) => `${id}-panel-${tab}`;
export const countClasses = 'font-normal text-muted-foreground tabular-nums';