RadioGroup
One choice from a few, marked by a single ink dot that slides to whatever you pick.
The idea
One dot rather than one per row, because there is only ever one choice and it should look like one thing moving, not a ring emptying while another fills — the same spring as every sliding highlight in the package. The rings are native radios sharing a name, which makes the group one tab stop with the arrows moving and choosing, skipping disabled options, for free. A first choice has nowhere to slide from, so it lands in place.
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 { RadioGroup } from "mihirmodi-ui";
<RadioGroup
label="Delivery"
value={delivery}
onChange={setDelivery}
options={[
{ value: "standard", label: "Standard", description: "3–5 working days · Free" },
{ value: "express", label: "Express", description: "Next working day · £6" },
{ value: "locker", label: "Parcel locker", disabled: true },
]}
/>Props
| Prop | Type | Notes |
|---|---|---|
| options(string | { value, label, description?, disabled? })[] | (string | { value, label, description?, disabled? })[] | The choices, in order. |
| value / onChangestring | null / (value) => void | string | null / (value) => void | Controlled. Or `defaultValue` to let it hold its own. |
| labelReactNode | ReactNode | A visible heading that also names the group. Otherwise pass `aria-label`. |
| orientation"vertical" | "horizontal" | "vertical" | "horizontal"= "vertical" | Horizontal wraps when it runs out of room. |
| namestring | string | What the form submits under. Generated when omitted. |
| disabled / requiredboolean | boolean | For the whole group. Disable one option on the option itself. |
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 { useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { FLAT, LIFT, SLIDE } from "../../lib/motion.js";
export type RadioOption = {
value: string;
label: ReactNode;
/** A quieter line under the label — a price, a delivery window. */
description?: ReactNode;
disabled?: boolean;
};
export type RadioGroupProps = {
/** Strings, or `{ value, label, description?, disabled? }`. */
options: (string | RadioOption)[];
/** Controlled. `null` for nothing chosen yet. */
value?: string | null;
defaultValue?: string | null;
onChange?: (value: string) => void;
/** Shared by every input, and what the form submits under. Generated when omitted. */
name?: string;
/** A visible heading for the group, which also names it. */
label?: ReactNode;
/** The accessible name, when there is no visible `label`. */
"aria-label"?: string;
orientation?: "vertical" | "horizontal";
disabled?: boolean;
required?: boolean;
className?: string;
};
/* An element's position inside `root`, by offsets rather than client rects, so
the answer is in the group's own pixels even when an ancestor is scaled. */
function offsetWithin(el: HTMLElement, root: HTMLElement) {
let x = 0;
let y = 0;
let n: HTMLElement | null = el;
while (n && n !== root) {
x += n.offsetLeft;
y += n.offsetTop;
n = n.offsetParent as HTMLElement | null;
}
return { x, y };
}
/**
* A set of options where exactly one is chosen, marked by a single ink dot
* that slides to whichever you pick.
*
* One dot, not one per row: there is only ever one choice, so it should look
* like one thing moving rather than one ring emptying while another fills. The
* dot travels on the same spring as every sliding highlight in the package, so
* a radio group and a segmented control feel like the same idea. The first
* choice in an empty group has nowhere to come from, so it lands in place
* instead of flying in from the corner.
*
* The rings are real `<input type="radio">`s sharing a `name`, which is what
* makes the group one tab stop with the arrow keys moving and choosing — the
* browser's own roving tabindex, which also skips disabled options and submits
* with a form. Re-implementing that on buttons would only be a chance to get
* it slightly wrong.
*/
export function RadioGroup({
options,
value,
defaultValue = null,
onChange,
name,
label,
"aria-label": ariaLabel,
orientation = "vertical",
disabled,
required,
className = "",
}: RadioGroupProps) {
const reduce = useReducedMotion();
const auto = useId();
const groupName = name ?? auto;
const labelId = `${auto}-label`;
const items = options.map((o) => (typeof o === "string" ? { value: o, label: o } : o)) as RadioOption[];
const [inner, setInner] = useState<string | null>(defaultValue);
const current = value !== undefined ? value : inner;
const idx = items.findIndex((o) => o.value === current);
const root = useRef<HTMLDivElement>(null);
const rings = useRef<(HTMLInputElement | null)[]>([]);
// Where the dot is, and how it should get there: slide from the last choice,
// land in place for a first choice, or simply be there — on mount, and when
// the layout reflows under it, neither of which is the reader choosing.
type Arrival = "still" | "land" | "slide";
const [dot, setDot] = useState<{ x: number; y: number; how: Arrival } | null>(null);
const prev = useRef<{ x: number; y: number } | null>(null);
const measured = useRef(false);
useLayoutEffect(() => {
const measure = (reflow: boolean) => {
const el = rings.current[idx];
if (!el || !root.current) {
prev.current = null;
measured.current = true;
setDot(null);
return;
}
const p = offsetWithin(el, root.current);
if (reflow && prev.current && prev.current.x === p.x && prev.current.y === p.y) return;
const how: Arrival = !measured.current || reflow ? "still" : prev.current ? "slide" : "land";
measured.current = true;
prev.current = p;
setDot({ ...p, how });
};
measure(false);
const ro = new ResizeObserver(() => measure(true));
if (root.current) ro.observe(root.current);
return () => ro.disconnect();
}, [idx, items.length, orientation]);
const pick = (v: string) => {
if (value === undefined) setInner(v);
onChange?.(v);
};
const travel = reduce ? FLAT : SLIDE;
const transition =
dot?.how === "slide"
? { x: travel, y: travel, scale: travel, opacity: { duration: 0.12 } }
: dot?.how === "land"
? { x: { duration: 0 }, y: { duration: 0 }, scale: reduce ? FLAT : LIFT, opacity: { duration: 0.12 } }
: { duration: 0 };
return (
<LazyMotion features={domAnimation} strict>
<div className={className}>
{label && (
<div id={labelId} className="mb-2.5 text-[13px] font-medium text-gray-1000">
{label}
</div>
)}
<div
ref={root}
role="radiogroup"
aria-labelledby={label ? labelId : undefined}
aria-label={label ? undefined : ariaLabel}
aria-orientation={orientation}
aria-required={required || undefined}
className={`relative flex ${orientation === "vertical" ? "flex-col gap-3" : "flex-row flex-wrap gap-x-6 gap-y-3"}`}
>
{items.map((o, i) => {
const off = disabled || o.disabled;
const descId = o.description ? `${auto}-${i}-desc` : undefined;
return (
<label
key={o.value}
className={`group flex select-none items-start gap-2.5 ${off ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
>
<span className="mt-0.5 flex shrink-0 transition-transform duration-150 ease-geist group-active:scale-90 motion-reduce:transition-none motion-reduce:group-active:scale-100">
<input
ref={(el) => {
rings.current[i] = el;
}}
type="radio"
name={groupName}
value={o.value}
checked={i === idx}
disabled={off}
required={required}
aria-describedby={descId}
onChange={() => pick(o.value)}
className="m-0 h-4 w-4 cursor-[inherit] appearance-none rounded-full border border-gray-alpha-600 bg-background-100 transition-colors duration-150 ease-out enabled:group-hover:bg-gray-100 motion-reduce:transition-none"
/>
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-[14px] leading-5 text-gray-1000">{o.label}</span>
{o.description && (
<span id={descId} className="text-[12px] leading-[18px] text-muted">
{o.description}
</span>
)}
</span>
</label>
);
})}
{/* The one dot. It covers the chosen ring; the ring underneath keeps
the focus outline, which is drawn outside it. */}
<m.span
aria-hidden
className="pointer-events-none absolute left-0 top-0 flex h-4 w-4 items-center justify-center rounded-full bg-ink"
initial={false}
animate={dot ? { x: dot.x, y: dot.y, scale: 1, opacity: 1 } : { scale: 0.6, opacity: 0 }}
transition={transition}
>
<span className="h-1.5 w-1.5 rounded-full bg-background-100" />
</m.span>
</div>
</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 },
};
}