## Segmented Control — Navigation Thumb slides, label inverts through it. Docs: https://www.interior.dev/docs/segmented-control Reference: https://www.interior.dev/reference/segmented-control License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/segmented-control.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { SegmentedControl } from "@/components/interior/segmented-control"; const VIEWS = [ { value: "board", label: "Board" }, { value: "list", label: "List" }, { value: "timeline", label: "Timeline" }, { value: "calendar", label: "Calendar", disabled: true }, ]; export function ViewSwitcher() { const [view, setView] = useState("board"); return (

Sprint 14

); } ``` ### Props - `options` (`SegmentedOption[]`) Each is { value, label, disabled? }. Segments are equal width, sized to the widest label. - `label` (`string`) Accessible name for the radiogroup. Required, because an unlabelled group of radios announces nothing about what it switches. - `value` (`string | undefined`) — default: `undefined` Controlled selection. Pass it with onValueChange to own the state. - `defaultValue` (`string | undefined`) — default: `options[0].value` Uncontrolled starting selection. Falls back to the first option. - `onValueChange` (`(value: string) => void`) — default: `undefined` Fires only when the selection actually changes, never on a click that re-selects the current segment. - `className` (`string`) — default: `""` Appended last to the track, so callers can override radius, border and surface. ### Behavior notes - The label does not change color when the click lands; it inverts where the thumb's edge crosses it, because the inverted copy is a second grid clipped to the thumb and translated by the negative of the thumb's own transform, so the two halves of a glyph can be two colors at once. - Segments never resize on selection: labels keep one weight in every state, both label layers share the same padding and type, and the track is a grid of equal fr columns, so the widest option fixes the geometry before anything moves. - The thumb spring is driven by a motion value written straight to the DOM, so a selection changed mid-flight resumes from where the thumb currently is instead of restarting from the old segment, and React never re-renders during the slide. - Under prefers-reduced-motion the thumb is placed on the new segment in one frame; the inversion still happens and no label is hidden. - It is a real radiogroup with roving tabindex: arrow keys move and select, Home and End jump to the first and last enabled option, disabled segments are skipped rather than focused, and a screen reader is given one label per segment instead of the three visual copies on screen. ### Source (`components/interior/segmented-control.tsx`) ```tsx "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { animate, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const SEG = "px-3 py-[7px] text-center text-[13px] font-medium leading-[18px] tracking-[-0.01em] whitespace-nowrap"; export type SegmentedOption = { value: string; label: string; disabled?: boolean; }; export type SegmentedControlProps = { options: SegmentedOption[]; label: string; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; className?: string; }; export function SegmentedControl({ options, label, value, defaultValue, onValueChange, className = "", }: SegmentedControlProps) { const count = Math.max(1, options.length); const template = `repeat(${count}, minmax(0, 1fr))`; const [internal, setInternal] = useState( () => defaultValue ?? options[0]?.value ?? "", ); const [hovered, setHovered] = useState(-1); const controlled = value !== undefined; const current = controlled ? value : internal; const found = options.findIndex((o) => o.value === current); const index = found < 0 ? 0 : found; const buttons = useRef<(HTMLButtonElement | null)[]>([]); const emit = useRef(onValueChange); emit.current = onValueChange; const reduced = useReducedMotion(); const pos = useMotionValue(index); const thumbX = useTransform(pos, (v) => `${v * 100}%`); const maskX = useTransform(pos, (v) => `${v * -100}%`); useEffect(() => { if (reduced) { pos.set(index); return; } const controls = animate(pos, index, CELL); return () => controls.stop(); }, [index, reduced, pos]); const select = useCallback( (next: string) => { if (!controlled) setInternal(next); if (next !== current) emit.current?.(next); }, [controlled, current], ); const seek = useCallback( (from: number, dir: number) => { let i = from; for (let k = 0; k < count; k++) { i = (i + dir + count) % count; if (!options[i]?.disabled) return i; } return from; }, [count, options], ); const go = useCallback( (i: number) => { const option = options[i]; if (!option || option.disabled) return; buttons.current[i]?.focus(); select(option.value); }, [options, select], ); const onKeyDown = (e: React.KeyboardEvent, i: number) => { if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); go(seek(i, 1)); } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); go(seek(i, -1)); } else if (e.key === "Home") { e.preventDefault(); go(seek(count - 1, 1)); } else if (e.key === "End") { e.preventDefault(); go(seek(0, -1)); } }; return (
{options.map((option, i) => ( {option.label} ))}
{options.map((option) => ( {option.label} ))}
setHovered(-1)} > {options.map((option, i) => ( ))}
); } ```