## 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 (