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 motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/segmented-control.jsonUsage
"use client";
import { useState } from "react";
import { SegmentedControl } from "@/components/yugo/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.