Tabs
NavigationThe APG tab pattern with the keyboard behaviour actually implemented — roving tabindex, arrow keys, Home and End.
Preview
Controls
Notes
Exactly one tab sits in the tab order and the arrow keys move between them. A tablist where every tab is tabbable makes the reader press Tab four times to get past a control they understood at a glance.
Selection follows focus, which is right when the panels are already rendered. If a panel fetches when it opens, switch to manual activation — otherwise arrowing across three tabs fires three requests nobody asked for.
The panel carries tabIndex={0} so the keyboard can reach text that holds no focusable child of its own, and aria-labelledby points back at its tab.
Tabs are not navigation. If each one should be linkable, shareable or in the back button, you want links and routes, not this.
Source
ShowHide
"use client";
import { useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
/**
* The APG tab pattern, which is mostly about the keyboard.
*
* Roving tabindex: exactly one tab is in the tab order, and the arrow keys move
* between them. A tablist where every tab is tabbable makes the reader press
* Tab four times to get past a control they already understood.
*
* Selection follows focus here (automatic activation), which is right when the
* panels are already rendered. If a panel FETCHES on open, switch to manual —
* otherwise arrowing across three tabs fires three requests nobody asked for.
*/
export type TabsVariant = "underline" | "pill";
export type TabItem = { label: string; content: React.ReactNode };
export function Tabs({
items,
label,
variant = "underline",
fullWidth = false,
className,
}: {
items: TabItem[];
/** Names the tablist for AT. Two tablists on a page are otherwise identical. */
label: string;
variant?: TabsVariant;
fullWidth?: boolean;
className?: string;
}) {
const base = useId();
const [active, setActive] = useState(0);
const buttons = useRef<Array<HTMLButtonElement | null>>([]);
function activate(index: number) {
const next = (index + items.length) % items.length;
setActive(next);
buttons.current[next]?.focus();
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const keys: Record<string, number> = {
ArrowRight: active + 1,
ArrowLeft: active - 1,
Home: 0,
End: items.length - 1,
};
const next = keys[event.key];
if (next === undefined) return;
event.preventDefault();
activate(next);
}
const current = items[active];
return (
<div className={cn("w-full", className)}>
<div
role="tablist"
aria-label={label}
onKeyDown={onKeyDown}
className={cn(
"flex",
variant === "underline"
? "gap-1 border-b border-rule"
: "gap-1 rounded-full border border-rule bg-paper-sunken p-1",
)}
>
{items.map((item, i) => {
const selected = i === active;
return (
<button
key={item.label}
ref={(element) => {
buttons.current[i] = element;
}}
type="button"
role="tab"
id={`${base}-tab-${i}`}
aria-selected={selected}
aria-controls={`${base}-panel-${i}`}
tabIndex={selected ? 0 : -1}
onClick={() => setActive(i)}
className={cn(
"cursor-pointer text-[14px] whitespace-nowrap transition-colors",
fullWidth && "flex-1",
variant === "underline"
? cn(
"-mb-px border-b-2 px-4 py-2.5",
selected
? "border-accent text-ink"
: "border-transparent text-ink-muted hover:text-ink",
)
: cn(
"rounded-full px-4 py-1.5",
selected
? "bg-paper-raised text-ink shadow-sm"
: "text-ink-muted hover:text-ink",
),
)}
>
{item.label}
</button>
);
})}
</div>
{/* tabIndex 0 so the keyboard can reach panel text that holds no
focusable child of its own. */}
<div
role="tabpanel"
id={`${base}-panel-${active}`}
aria-labelledby={`${base}-tab-${active}`}
tabIndex={0}
className="mt-5 text-[15px] leading-relaxed text-ink-muted"
>
{current.content}
</div>
</div>
);
}
const panels: TabItem[] = [
{ label: "Discovery", content: "A short paid week mapping the problem, the data and who actually touches it. You get the map whether or not we build anything." },
{ label: "Build", content: "A fixed scope for the first working slice, shipped to a real environment early rather than demoed at the end." },
{ label: "Handover", content: "You own the code and the accounts from the first commit. The handover is documentation and a walkthrough, not a negotiation." },
{ label: "After", content: "I stay on for as long as it is useful and no longer. Most engagements taper to a monthly check-in." },
];
export default function Demo({
count = "3",
variant = "underline",
fullWidth = false,
}: {
count?: string;
variant?: TabsVariant;
fullWidth?: boolean;
}) {
const shown = panels.slice(0, Math.max(2, Number(count) || 2));
return (
<div className="w-full max-w-md">
{/* Keyed on the count so the active index cannot outlive a tab. */}
<Tabs key={shown.length} items={shown} label="Engagement stages" variant={variant} fullWidth={fullWidth} />
</div>
);
}
Next
SwitchNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.