v1.0

Tabs

Preview

Basic

Loading…

Preview

Code

ts
import Tabs from '@/components/layout/Tabs';

src/components/layout/Tabs.tsx

AI prompt

text
Build an accessible underline tabs component in React + TypeScript + Tailwind CSS.

## Look
- Root `flex min-h-0 flex-col`. Tab strip: `flex shrink-0 items-center gap-1 border-b border-slate-200 dark:border-slate-700`.
- Tab: `-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-xs font-medium` — the `-mb-px` puts the active underline ON the strip's border.
- Active: `border-indigo-600 text-indigo-600 dark:text-indigo-400`. Inactive: transparent border, `text-slate-500 hover:text-slate-700` (dark `slate-400 → slate-200`). Disabled: `opacity-40`. Focus: `focus-visible:ring-2 ring-indigo-400`, no outline.
- Optional count badge after the label: `rounded-full bg-slate-100 px-1.5 text-[10px] font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300`.
- Panel area below: `min-h-0 flex-1 pt-3`.

## Behaviour
- Uncontrolled until `value` is passed (starts at `defaultValue`, else the first tab), so a simple panel switch needs no state and a URL-driven one still works. `onChange(id)` fires in both modes.
- `children` is a render function `(activeId) => ReactNode`: the caller decides which panel to show; omit it for a bare tab strip.
- ArrowLeft / ArrowRight move to the previous/next tab, wrap around, skip disabled tabs, and select as they go (focus follows). Roving tabindex: only the active tab is `tabIndex=0`, the rest `-1`.

## API
`tabs: { id: string; label: ReactNode; badge?: ReactNode; disabled?: boolean }[]`, `value?`, `defaultValue?`, `onChange?: (id: string) => void`, `className`, `children?: (activeId: string) => ReactNode`.

## Accessibility
`role="tablist"` on the strip; each tab `role="tab"` with `aria-selected` and `aria-controls`; the panel `role="tabpanel"` with `aria-labelledby` pointing at the active tab (ids from `useId`).

## Demo
Overview, Members (badge 8), Billing, Archived (disabled). The panel is a `rounded-lg bg-slate-50 dark:bg-slate-800/50 p-4 text-xs` box reading "Panel: **overview**".

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useId, useRef, useState } from 'react';
import { cn } from '@/lib/cn';

export type Tab = { id: string; label: React.ReactNode; badge?: React.ReactNode; disabled?: boolean };

/**
 * Uncontrolled until you pass `value`, so a simple panel switch needs no state
 * and a URL-driven one still works.
 *
 * Arrow keys move between tabs and skip disabled ones — a tablist that is only
 * clickable is not reachable for anyone navigating by keyboard, and the roving
 * tabindex below is what the ARIA pattern actually requires.
 */
export default function Tabs({
  tabs,
  value,
  defaultValue,
  onChange,
  className,
  children,
}: {
  tabs: Tab[];
  value?: string;
  defaultValue?: string;
  onChange?: (id: string) => void;
  className?: string;
  /** Rendered below the tab strip — the caller owns which panel to show. */
  children?: (activeId: string) => React.ReactNode;
}) {
  const base = useId();
  const [internal, setInternal] = useState(defaultValue ?? tabs[0]?.id);
  const active = value ?? internal;
  const refs = useRef<Record<string, HTMLButtonElement | null>>({});

  const select = (id: string) => {
    if (value === undefined) setInternal(id);
    onChange?.(id);
  };

  const onKey = (e: React.KeyboardEvent) => {
    const usable = tabs.filter((t) => !t.disabled);
    const i = usable.findIndex((t) => t.id === active);
    if (i === -1) return;
    const delta = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
    if (!delta) return;
    e.preventDefault();
    const next = usable[(i + delta + usable.length) % usable.length];
    select(next.id);
    refs.current[next.id]?.focus();
  };

  return (
    <div className={cn('flex min-h-0 flex-col', className)}>
      <div role="tablist" onKeyDown={onKey} className="flex shrink-0 items-center gap-1 border-b border-slate-200 dark:border-slate-700">
        {tabs.map((t) => {
          const on = t.id === active;
          return (
            <button
              key={t.id}
              ref={(el) => { refs.current[t.id] = el; }}
              role="tab"
              id={`${base}-tab-${t.id}`}
              aria-selected={on}
              aria-controls={`${base}-panel-${t.id}`}
              tabIndex={on ? 0 : -1}
              disabled={t.disabled}
              onClick={() => select(t.id)}
              className={cn(
                '-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-xs font-medium transition-colors',
                'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 disabled:opacity-40',
                on
                  ? 'border-indigo-600 text-indigo-600 dark:text-indigo-400'
                  : 'border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200',
              )}
            >
              {t.label}
              {t.badge != null && (
                <span className="rounded-full bg-slate-100 px-1.5 text-[10px] font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                  {t.badge}
                </span>
              )}
            </button>
          );
        })}
      </div>
      {children && (
        <div role="tabpanel" id={`${base}-panel-${active}`} aria-labelledby={`${base}-tab-${active}`} className="min-h-0 flex-1 pt-3">
          {children(active)}
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
tabs*Tab[]—
valuestring—
defaultValuestring—
onChange(id: string) => void—
classNamestring—
children(activeId: string) => React.ReactNode—Rendered below the tab strip — the caller owns which panel to show.