Segmented Control
A segmented control whose indicator slides between options, measured from the live DOM so options of different widths land correctly — percentage maths only looks right when every label happens to be the same length. The substance is the keyboard model: it is a real WAI-ARIA radiogroup, so arrows move and select, Home and End jump to the ends, and the whole group is one tab stop rather than one per option.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Segmented } from "@/components/segmented"; <Segmented />Customise
The component
components/craft/interface/Segmented.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useId, useRef, useState } from "react"; /** * A segmented control with an indicator that slides between options. * * The interesting part is not the slide, it is the keyboard model. This is a * real radio group: arrow keys move between options and change the selection, * Home and End jump to the ends, and the group holds ONE tab stop so a * keyboard user tabs past it in one press instead of once per option. That is * the WAI-ARIA radiogroup pattern, and it is what almost every pretty * segmented control on the internet gets wrong. * * The indicator is measured from the live DOM rather than computed from * index × width, so options of different widths work. Percentage maths only * looks right when every label happens to be the same length. */ export interface SegmentedOption { value: string; label: string;} export interface SegmentedProps { options: SegmentedOption[]; value?: string; onChange?: (value: string) => void; /** Accessible name for the group. Required — it has no visible label. */ label: string; accent?: string; /** Slide duration in ms. */ duration?: number; size?: "sm" | "md"; /** Force an instant move rather than a slide. */ still?: boolean; className?: string;} export function Segmented({ options, value, onChange, label, accent = "#c3f53c", duration = 320, size = "md", still = false, className = "",}: SegmentedProps) { const groupId = useId(); // Uncontrolled by default so it is useful straight out of the box, but a // `value` prop takes over the moment one is supplied. const [internal, setInternal] = useState(options[0]?.value ?? ""); const selected = value ?? internal; const listRef = useRef<HTMLDivElement>(null); const [indicator, setIndicator] = useState({ left: 0, width: 0, ready: false }); const select = (next: string) => { if (value === undefined) setInternal(next); onChange?.(next); }; // Measured from the DOM, so unequal label widths land correctly. useEffect(() => { const list = listRef.current; if (!list) return; const measure = () => { const active = list.querySelector<HTMLElement>(`[data-value="${CSS.escape(selected)}"]`); if (!active) return; setIndicator({ left: active.offsetLeft, width: active.offsetWidth, ready: true, }); }; measure(); // Fonts land after first paint and change label widths; without this the // indicator sits slightly wrong until the next interaction. const observer = new ResizeObserver(measure); observer.observe(list); document.fonts?.ready.then(measure).catch(() => {}); return () => observer.disconnect(); }, [selected, options]); const onKeyDown = (event: React.KeyboardEvent) => { const index = options.findIndex((o) => o.value === selected); let next = index; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = index + 1; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = index - 1; else if (event.key === "Home") next = 0; else if (event.key === "End") next = options.length - 1; else return; event.preventDefault(); // Wraps, which the radiogroup pattern expects. const wrapped = (next + options.length) % options.length; select(options[wrapped].value); listRef.current ?.querySelector<HTMLElement>(`[data-value="${CSS.escape(options[wrapped].value)}"]`) ?.focus(); }; const pad = size === "sm" ? "px-3 py-1.5 text-[0.75rem]" : "px-4 py-2 text-sm"; return ( <div ref={listRef} role="radiogroup" aria-label={label} onKeyDown={onKeyDown} className={`relative inline-flex rounded-full border border-ink-600 bg-ink-800 p-1 ${className}`} > {/* Purely decorative; the selected state is carried by aria-checked. */} <span aria-hidden className="absolute top-1 bottom-1 rounded-full" style={{ left: indicator.left, width: indicator.width, background: accent, transition: still || !indicator.ready ? "none" : `left ${duration}ms cubic-bezier(0.16,1,0.3,1), width ${duration}ms cubic-bezier(0.16,1,0.3,1)`, // Hidden until measured, or it animates in from the left edge on mount. opacity: indicator.ready ? 1 : 0, }} /> {options.map((option) => { const isSelected = option.value === selected; return ( <button key={option.value} type="button" role="radio" aria-checked={isSelected} data-value={option.value} id={`${groupId}-${option.value}`} // One tab stop for the whole group: tab enters, arrows navigate. tabIndex={isSelected ? 0 : -1} onClick={() => select(option.value)} className={`relative z-10 rounded-full font-mono tracking-[0.04em] whitespace-nowrap transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current ${pad} ${ isSelected ? "text-ink-900" : "text-bone-400 hover:text-bone-100" }`} > {option.label} </button> ); })} </div> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+1.8KB gzipped · no new dependencies
- Runs on
Compositor
Animates transform and opacity only, so the compositor handles it off the main thread. Scroll and input stay responsive even while it runs.
- Reduced motion
The indicator moves instantly instead of sliding. Selection still reads clearly — the transition is decoration, not information.
- Accessibility
- role=radiogroup with roving tabindex: one tab stop for the group.
- Arrow keys move and select with wrapping; Home and End jump to the ends.
- Selection is carried by aria-checked, not by colour alone.
- Visible focus ring retained rather than suppressed for looks.
- Watch out
- Needs an accessible name via the label prop — it has no visible label of its own.
- Re-measures on resize and after fonts load; without the font hook the indicator sits slightly wrong until first interaction.