ToggleGroup
A segmented control whose one highlight slides, or a row of toggles that each stay down.
The idea
One thing being chosen should look like one thing moving, so single-choice is a radiogroup with a single highlight that slides between options — arrows move it, Tab leaves the group. Several things down at once cannot slide, so multiple-choice gives each pressed item its own tile, fading in place: the same surface and the same shadow, only the number of them differs.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { ToggleGroup } from "mihirmodi-ui";
// Single: one highlight that slides. Arrows move it.
<ToggleGroup
label="Range"
value={range}
onChange={setRange}
items={[
{ value: "day", children: "Day" },
{ value: "week", children: "Week" },
{ value: "month", children: "Month" },
]}
/>
// Multiple: each pressed item gets its own tile.
<ToggleGroup type="multiple" label="Formatting" value={marks} onChange={setMarks}
items={[{ value: "b", label: "Bold", children: <b>B</b> }, …]} />Props
| Prop | Type | Notes |
|---|---|---|
| type"single" | "multiple" | "single" | "multiple"= "single" | Single takes a string value; multiple takes a string[]. |
| items{ value, children, label?, disabled? }[] | { value, children, label?, disabled? }[] | `children` may be a function of whether it is selected, so a glyph can fill. |
| labelstring | string | The group's accessible name. Required. |
| variant"plain" | "glass" | "plain" | "glass"= "plain" | A grey well on the page, or glass over content. |
| size"sm" | "md" | "sm" | "md"= "md" | 28 or 32px items. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { FLAT, SLIDE } from "../../lib/motion.js";
export type ToggleGroupItem = {
value: string;
/** The visible content. For an icon-only item, give `label` too. */
children: ReactNode | ((selected: boolean) => ReactNode);
/** The accessible name, when the content is only a glyph. */
label?: string;
disabled?: boolean;
};
type Single = { type?: "single"; value: string; onChange: (value: string) => void };
type Multiple = { type: "multiple"; value: string[]; onChange: (value: string[]) => void };
export type ToggleGroupProps = (Single | Multiple) & {
items: ToggleGroupItem[];
/** The group's accessible name. */
label: string;
/** `glass` floats over content, like the gallery's layout switch. `plain` sits on a grey well. */
variant?: "plain" | "glass";
size?: "sm" | "md";
className?: string;
};
/**
* A row of toggles that share one surface.
*
* Single-choice is a segmented control: one highlight that slides between the
* options, because one thing being chosen should look like one thing moving,
* not two tiles blinking. It is a radiogroup — the arrow keys move the choice
* and Tab leaves the group, which is what a reader of radios expects.
*
* Multiple-choice — bold, italic, underline — cannot slide, since several can
* be down at once, so each pressed item gets its own highlight tile instead,
* fading in place. Same surface, same shadow; only the number of them differs.
*/
export function ToggleGroup(props: ToggleGroupProps) {
const { items, label, variant = "plain", size = "md", className = "" } = props;
const reduce = useReducedMotion();
const multiple = props.type === "multiple";
const isOn = (v: string) => (multiple ? (props.value as string[]).includes(v) : props.value === v);
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const [pill, setPill] = useState<{ x: number; w: number } | null>(null);
const current = multiple ? -1 : items.findIndex((i) => i.value === props.value);
useLayoutEffect(() => {
const el = refs.current[current];
setPill(el ? { x: el.offsetLeft, w: el.offsetWidth } : null);
}, [current, items.length]);
const press = (v: string) => {
if (props.type === "multiple") {
props.onChange(props.value.includes(v) ? props.value.filter((x) => x !== v) : [...props.value, v]);
} else {
props.onChange(v);
}
};
const onKey = (e: KeyboardEvent, i: number) => {
if (multiple) return;
const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
if (!step) return;
e.preventDefault();
for (let n = 1; n <= items.length; n++) {
const j = (i + step * n + items.length) % items.length;
if (!items[j].disabled) {
press(items[j].value);
refs.current[j]?.focus();
return;
}
}
};
const h = size === "sm" ? "h-7 text-[12px] px-2.5 min-w-7" : "h-8 text-[13px] px-3 min-w-8";
const tile = "rounded-[8px] bg-highlight shadow-[0_0_0_0.5px_var(--color-gray-alpha-400),0_2px_2px_rgba(0,0,0,0.04)]";
const well =
variant === "glass"
? "glass-nav dock-shadow border-[0.5px] border-gray-alpha-400 rounded-[12px]"
: "bg-gray-alpha-100 rounded-[11px]";
return (
<LazyMotion features={domAnimation} strict>
<div role={multiple ? "group" : "radiogroup"} aria-label={label} className={`relative inline-flex items-center p-[3px] ${well} ${className}`}>
{!multiple && pill && (
<m.span
aria-hidden
className={`absolute bottom-[3px] left-0 top-[3px] ${tile}`}
initial={false}
animate={{ x: pill.x, width: pill.w }}
transition={reduce ? FLAT : SLIDE}
/>
)}
{items.map((it, i) => {
const on = isOn(it.value);
return (
<button
key={it.value}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role={multiple ? undefined : "radio"}
aria-checked={multiple ? undefined : on}
aria-pressed={multiple ? on : undefined}
aria-label={it.label}
disabled={it.disabled}
tabIndex={multiple || on || (current < 0 && i === 0) ? 0 : -1}
onClick={() => press(it.value)}
onKeyDown={(e) => onKey(e, i)}
className={`relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium leading-none transition-colors duration-150 disabled:opacity-40 ${h} ${
on ? "text-gray-1000" : "text-quiet hover:text-gray-1000"
}`}
>
{multiple && (
<span
aria-hidden
className={`absolute inset-0 ${tile} transition-opacity duration-150 motion-reduce:transition-none ${on ? "opacity-100" : "opacity-0"}`}
/>
)}
<span className="relative inline-flex items-center gap-1.5">
{typeof it.children === "function" ? it.children(on) : it.children}
</span>
</button>
);
})}
</div>
</LazyMotion>
);
}
/**
* The motion presets the newer components share. The older ones (Dock,
* DropMenu, HighlightList) carry their own copies of the same numbers, which is
* deliberate: each of them documents why its curve is what it is, in place.
*/
/** One slide for anything that re-targets its position. */
export const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4 } as const;
/** Something arriving that should land with a small settle. */
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
/** The disagreeing pair behind squash-and-stretch: see DropMenu. */
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
/** Closing is quick and flat. A bounce on the way out reads as indecision. */
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
/** Under reduced motion: a short plain fade, nothing that travels. */
export const FLAT = { duration: 0.15 } as const;
/** Expo-out, for anything that also drives a blur — it must not overshoot. */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;
/** The house curve: overshoots a touch, for transforms only. */
export const EASE_GEIST = [0.175, 0.885, 0.32, 1.1] as const;
/** A block entering a list: fade and 4px. Opacity never springs. */
export function enter(reduce: boolean | null) {
return {
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
animate: { opacity: 1, y: 0 },
transition: reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST },
};
}