Tabs
Keyboard-navigable tabs in underline and segmented variants, with optional counts.
import { Tabs }from "@/components/ui"
Underline & segmented
Full source
The complete component, read from src/components/ui/tabs.tsx.
tabs.tsx
"use client";
import { useRef } 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: React.KeyboardEvent) => {
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>
);
}