Skip to content
Interface

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.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Segmented } from "@/components/segmented"; <Segmented />

Customise

320ms

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.