Navigation·06.2
Segmented Control
Thumb slides, label inverts through it.
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionUsage
"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 (
<div className="flex items-center justify-between">
<h2 className="text-[15px] font-medium">Sprint 14</h2>
<SegmentedControl
label="View mode"
options={VIEWS}
value={view}
onValueChange={setView}
/>
</div>
);
}Source
"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 (
<div
role="radiogroup"
aria-label={label}
className={`relative inline-block select-none rounded-[9px] border border-stone-200 bg-stone-100/70 p-[3px] shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)] ${className}`}
>
<div
className="relative grid"
style={{ gridTemplateColumns: template, touchAction: "manipulation" }}
>
{options.map((option, i) => (
<span
key={option.value}
aria-hidden
className={`${SEG} pointer-events-none ${
option.disabled
? "text-stone-300 dark:text-stone-600"
: hovered === i && i !== index
? "text-stone-700 dark:text-stone-200"
: "text-stone-500 dark:text-stone-400"
}`}
>
{option.label}
</span>
))}
<motion.div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 overflow-hidden rounded-[6px] bg-stone-800 shadow-[0_1px_2px_rgba(28,25,23,0.28)] dark:bg-stone-100 dark:shadow-[0_1px_2px_rgba(0,0,0,0.5)]"
style={{ width: `${100 / count}%`, x: thumbX }}
initial={false}
>
<motion.div
className="absolute inset-0"
style={{ x: maskX }}
initial={false}
>
<div
className="absolute inset-y-0 left-0 grid"
style={{ width: `${count * 100}%`, gridTemplateColumns: template }}
>
{options.map((option) => (
<span
key={option.value}
className={`${SEG} text-stone-50 dark:text-stone-900`}
>
{option.label}
</span>
))}
</div>
</motion.div>
</motion.div>
<div
className="absolute inset-0 grid"
style={{ gridTemplateColumns: template }}
onPointerLeave={() => setHovered(-1)}
>
{options.map((option, i) => (
<button
key={option.value}
ref={(node) => {
buttons.current[i] = node;
}}
type="button"
role="radio"
aria-checked={i === index}
aria-disabled={option.disabled || undefined}
tabIndex={i === index ? 0 : -1}
onClick={() => !option.disabled && select(option.value)}
onKeyDown={(e) => onKeyDown(e, i)}
onPointerEnter={() => !option.disabled && setHovered(i)}
className="cursor-default rounded-[6px] outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.08] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
>
<span className="sr-only">{option.label}</span>
</button>
))}
</div>
</div>
</div>
);
}Props
optionsSegmentedOption[]Each is { value, label, disabled? }. Segments are equal width, sized to the widest label.
labelstringAccessible name for the radiogroup. Required, because an unlabelled group of radios announces nothing about what it switches.
valueundefinedstring | undefinedControlled selection. Pass it with onValueChange to own the state.
defaultValueoptions[0].valuestring | undefinedUncontrolled starting selection. Falls back to the first option.
onValueChangeundefined(value: string) => voidFires only when the selection actually changes, never on a click that re-selects the current segment.
className""stringAppended last to the track, so callers can override radius, border and surface.