Toggle Group Select
One highlight slides between grouped buttons and resizes to each label.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Toggle Group Select
*
* One highlight for the whole group. Choosing another option slides the
* selection across and resizes it to the new label, and the helper line
* underneath hands over to match.
*
* A proper radio group: arrow keys move the choice, Home and End jump to
* the ends, and only the selected option is in the tab order.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so it reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `options`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ToggleOption = {
/** Stable value. */
value: string;
/** Button label. */
label: string;
/** Line shown under the group while this option is chosen. */
hint: string;
};
export type ToggleGroupSelectProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Options, in order. */
options?: ToggleOption[];
/** Group label. */
label?: string;
/** Which option starts chosen. */
defaultValue?: string;
/** Selection colour. */
accent?: string;
/** Fires with the chosen value. */
onChange?: (value: string) => void;
};
type VariantConfig = {
/** px the helper line travels as it hands over. */
hintTravel: number;
slideSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the highlight is the only thing that moves, and its
// spring sits well above a 0.8 damping ratio — a selection that
// overshoots sits on the wrong option for a frame, which is worse than
// no motion at all. Labels are text: their colour transitions, their
// size never does.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// The indicator's travel is fixed by where the segments are, so the
// only axis here is time. Subtle spends almost none: ζ = 1.0, settled
// in about 130ms. For settings panels full of these.
subtle: {
hintTravel: 2,
slideSpring: { type: "spring", stiffness: 900, damping: 60 },
},
// The slide is followed easily and never waited on. ζ ≈ 0.95 — the
// all-purpose setting.
default: {
hintTravel: 5,
slideSpring: { type: "spring", stiffness: 400, damping: 38 },
},
// A softer glide with one gentle settle, taking a little over twice
// as long as subtle to arrive. For a prominent choice.
playful: {
hintTravel: 14,
slideSpring: { type: "spring", stiffness: 210, damping: 27 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_OPTIONS: ToggleOption[] = [
{
value: "monthly",
label: "Monthly",
hint: "Charged on the 1st. Cancel any time before renewal.",
},
{
value: "quarterly",
label: "Quarterly",
hint: "One invoice every three months, with a 10% discount.",
},
{
value: "yearly",
label: "Yearly",
hint: "One invoice a year, with two months included free.",
},
];
export default function ToggleGroupSelect({
variant = "default",
options = DEFAULT_OPTIONS,
label = "Billing period",
defaultValue,
accent = "#5B5BD6",
onChange,
}: ToggleGroupSelectProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const groupId = useId();
const pillId = `${groupId}-pill`;
const [value, setValue] = useState(defaultValue ?? options[0]?.value ?? "");
const [ring, setRing] = useState<string | null>(null);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const selectedIndex = Math.max(
0,
options.findIndex((option) => option.value === value)
);
const selected = options[selectedIndex];
const choose = (index: number, moveFocus: boolean) => {
const option = options[index];
if (!option) return;
setValue(option.value);
onChange?.(option.value);
if (moveFocus) buttons.current[index]?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const last = options.length - 1;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
choose(selectedIndex === last ? 0 : selectedIndex + 1, true);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault();
choose(selectedIndex === 0 ? last : selectedIndex - 1, true);
} else if (event.key === "Home") {
event.preventDefault();
choose(0, true);
} else if (event.key === "End") {
event.preventDefault();
choose(last, true);
}
};
return (
<div style={{ width: 292, color: "inherit" }}>
<span
id={`${groupId}-label`}
style={{
display: "block",
marginBottom: 7,
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.2,
opacity: 0.6,
}}
>
{label}
</span>
<div
role="radiogroup"
aria-labelledby={`${groupId}-label`}
onKeyDown={onKeyDown}
style={{
display: "flex",
gap: 3,
padding: 3,
borderRadius: 11,
background: tone(7),
border: `1px solid ${tone(12)}`,
}}
>
{options.map((option, index) => {
const isSelected = index === selectedIndex;
return (
<button
key={option.value}
ref={(node) => {
buttons.current[index] = node;
}}
type="button"
role="radio"
aria-checked={isSelected}
// Roving tab order: one stop for the group, arrows inside.
tabIndex={isSelected ? 0 : -1}
onClick={() => choose(index, false)}
onFocus={(event) =>
setRing(
event.currentTarget.matches(":focus-visible") ? option.value : null
)
}
onBlur={() => setRing(null)}
style={{
position: "relative",
flex: 1,
padding: "7px 10px",
fontFamily: "inherit",
fontSize: 12.5,
fontWeight: 650,
color: isSelected ? "#fff" : "inherit",
opacity: isSelected ? 1 : 0.62,
background: "transparent",
border: "none",
borderRadius: 8,
cursor: "pointer",
boxShadow: ring === option.value ? `0 0 0 3px ${tone(24)}` : "none",
outline: "none",
// Colour settles on a CSS transition so the animation
// loop stays transform-only.
transition: "color 180ms ease-out, opacity 180ms ease-out",
}}
>
{/* One highlight for the whole group: it is mounted inside
whichever option is chosen, and shared layout carries it
across — resizing to each label without ever touching
the text. */}
{isSelected && (
<motion.span
layoutId={pillId}
aria-hidden
transition={reduceMotion ? { duration: 0 } : cfg.slideSpring}
style={{
position: "absolute",
inset: 0,
borderRadius: 8,
background: accent,
zIndex: 0,
}}
/>
)}
<span style={{ position: "relative", zIndex: 1 }}>
{option.label}
</span>
</button>
);
})}
</div>
<div style={{ position: "relative", height: 32, marginTop: 8 }}>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={selected?.value}
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.hintTravel }
}
animate={{ opacity: 0.55, y: 0 }}
exit={{
opacity: 0,
transition: { duration: 0.1, ease: "easeIn" },
}}
transition={{ duration: reduceMotion ? 0.12 : 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
margin: 0,
fontSize: 11.5,
lineHeight: 1.4,
}}
>
{selected?.hint}
</motion.p>
</AnimatePresence>
</div>
</div>
);
}About this pattern
A grouped choice with a single highlight rather than three that light up and go out. The highlight is mounted inside whichever option is chosen and shared layout carries it across, resizing to the new label without ever touching the text — labels change colour on a CSS transition so the animation loop stays transform-only. The helper line under the group hands over to match. It behaves like a real radio group: arrow keys move the choice, Home and End jump to the ends, and only the selected option sits in the tab order.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Form
The selection glides between options and resizes to each label.
Related patterns
- Checkbox Check DrawThe box fills from its center and the tick strokes itself in over the fill.
- Multi Select ChipsPicked options become chips in the row above the list, and the chips already there slide over to take each new one in.
- Toggle Switch SlideThe knob travels across the track while the accent fill comes up under it, settling once.