Tabs

Keyboard-navigable tabs in underline and segmented variants, with optional counts.

import { Tabs }from "@/components/ui"

Specification

Switches between sibling views within the same context, with keyboard navigation and optional counts.

Props (4)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
items*TabItem[]—Tabs to render; each has id, label and optional count, urgent and disabled flags.
value*string—The id of the currently active tab.
onChange*(id: string) => void—Called with the newly selected tab id.
variant"underline" | "segmented""underline"Chooses the underline tab bar or a compact segmented control.

Underline & segmented

Full source
The complete component, read from src/components/ui/tabs.tsx.
tabs.tsx
"use client";

import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { cn } from "@/lib/utils";

interface TabItem {
  id: string;
  label: string;
  count?: number;
  urgent?: boolean;
  disabled?: boolean;
}

interface TabsProps {
  items: TabItem[];
  value: string;
  onChange: (id: string) => void;
  variant?: "underline" | "segmented";
}

export function Tabs({ items, value, onChange, variant = "underline" }: TabsProps) {
  const refs = useRef<Record<string, HTMLButtonElement | null>>({});

  const onKeyDown = (e: ReactKeyboardEvent) => {
    const enabled = items.filter((i) => !i.disabled);
    const i = enabled.findIndex((t) => t.id === value);
    let next: string | undefined;
    if (e.key === "ArrowRight") next = enabled[(i + 1) % enabled.length]?.id;
    else if (e.key === "ArrowLeft") next = enabled[(i - 1 + enabled.length) % enabled.length]?.id;
    else if (e.key === "Home") next = enabled[0]?.id;
    else if (e.key === "End") next = enabled[enabled.length - 1]?.id;
    if (!next) return;
    e.preventDefault();
    onChange(next);
    refs.current[next]?.focus();
  };

  if (variant === "segmented") {
    return (
      <div
        role="tablist"
        onKeyDown={onKeyDown}
        className="inline-flex rounded-md border border-border bg-bg-secondary p-0.5"
      >
        {items.map((it) => {
          const on = it.id === value;
          return (
            <button
              key={it.id}
              ref={(el) => { refs.current[it.id] = el; }}
              role="tab"
              aria-selected={on}
              onClick={() => !it.disabled && onChange(it.id)}
              disabled={it.disabled}
              className={cn(
                "rounded px-3 py-1 text-sm font-medium transition-colors",
                "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
                "disabled:cursor-not-allowed disabled:opacity-40",
                on ? "bg-surface text-text shadow-sm" : "text-text-secondary"
              )}
            >
              {it.label}
            </button>
          );
        })}
      </div>
    );
  }

  return (
    <div
      role="tablist"
      onKeyDown={onKeyDown}
      className="flex items-end gap-1 overflow-x-auto overflow-y-hidden border-b border-border"
    >
      {items.map((it) => {
        const on = it.id === value;
        return (
          <button
            key={it.id}
            ref={(el) => { refs.current[it.id] = el; }}
            role="tab"
            aria-selected={on}
            disabled={it.disabled}
            onClick={() => !it.disabled && onChange(it.id)}
            className={cn(
              "relative flex h-10 shrink-0 items-center gap-2 px-3 text-sm font-medium",
              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
              "disabled:cursor-not-allowed disabled:opacity-40",
              on ? "text-text" : "text-text-secondary"
            )}
          >
            {it.label}
            {it.count !== undefined && (
              <span
                className={cn(
                  "rounded px-1.5 py-0.5 text-xs tabular-nums",
                  it.urgent ? "font-semibold" : "font-medium",
                  on
                    ? "bg-accent-light text-accent"
                    : it.urgent
                      ? "bg-bg-secondary text-warning"
                      : "bg-bg-secondary text-text-secondary"
                )}
              >
                {it.count.toLocaleString()}
              </span>
            )}
            {on && <span className="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-accent" />}
          </button>
        );
      })}
    </div>
  );
}