Skip to content
Andrew SmithSolutions
← Component library

Tabs

Navigation

The APG tab pattern with the keyboard behaviour actually implemented — roving tabindex, arrow keys, Home and End.

ARIARoving tabindexKeyboardCSS only — no dependency

Preview

A short paid week mapping the problem, the data and who actually touches it. You get the map whether or not we build anything.

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

Show
"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

Switch

Next 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.