Radio Group
pnpm dlx shadcn@latest add …/r/radio-group.jsonAn accessible React radio group for Next.js forms, where the selected dot travels to the new choice, built on Radix Radio Group.
- Slide
- Stretch
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
export function Density() {
return (
<RadioGroup defaultValue="comfortable" aria-label="Density">
<div>
<RadioGroupItem id="comfortable" value="comfortable" />
<label htmlFor="comfortable">Comfortable</label>
</div>
<div>
<RadioGroupItem id="compact" value="compact" />
<label htmlFor="compact">Compact</label>
</div>
</RadioGroup>
)
}"use client";
import {
RadioGroup,
RadioGroupItem,
type RadioGroupTravel,
} from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useId, useState } from "react";
type Size = "sm" | "md";
const travels: { travel: RadioGroupTravel; label: string }[] = [
{ travel: "slide", label: "Slide" },
{ travel: "stretch", label: "Stretch" },
];
const options = ["Default", "Comfortable", "Compact"];
/*
* Both ways of travelling side by side, named underneath, with the size in the top-right corner. An empty
* row at the bottom, as tall as the setting's row, keeps them centred.
*/
export default function RadioGroupTravelDemo() {
const id = useId();
const [size, setSize] = useState<Size>("md");
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-size`} className="text-sm text-muted-foreground">
Size
</label>
<Select value={size} onValueChange={(value) => setSize(value as Size)}>
<SelectTrigger id={`${id}-size`} size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm">Small</SelectItem>
<SelectItem value="md">Medium</SelectItem>
</SelectContent>
</Select>
</div>
<ul className="flex flex-1 flex-wrap items-center justify-center gap-x-16 gap-y-8">
{travels.map(({ travel, label }) => (
<li key={travel} className="flex flex-col items-center gap-4">
<RadioGroup
defaultValue="comfortable"
travel={travel}
size={size}
aria-label={`Density, ${label.toLowerCase()}`}
>
{options.map((option) => (
<div key={option} className="flex items-center gap-3">
<RadioGroupItem id={`${id}-${travel}-${option}`} value={option.toLowerCase()} />
<label htmlFor={`${id}-${travel}-${option}`} className="text-sm">
{option}
</label>
</div>
))}
</RadioGroup>
<span className="text-xs text-muted-foreground">{label}</span>
</li>
))}
</ul>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { duration, spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva } from "class-variance-authority"; import { type AnimationPlaybackControls, animate, motion, useMotionValue, useReducedMotion, } from "motion/react"; import { RadioGroup as RadioGroupPrimitive } from "radix-ui"; import { type ComponentProps, createContext, useContext, useEffect, useLayoutEffect, useRef, useState, } from "react"; /* * Radio Group (owner, 2026-10-15; chosen in the Lab). * - The selection travels: when the choice changes, the dot leaves the old radio and lands in the new one. * Two ways (`travel`), both chosen by the owner: * - slide (the default): straight across on spring.snappy * - stretch: the front leaves first and the back follows a moment later, so it stretches into a capsule * for the trip and lands round, like Switch's thumb * - At rest each dot sits inside its own radio, so nothing is measured until the choice changes. For a * trip, one shared dot moves at the group's level, placed with offsets (layout numbers, so a zoomed or * scaled preview doesn't double the movement), then hands back to the new radio's own dot in the same * frame. * - Nothing animates on first paint. With reduced motion the dot moves at once. */ export type RadioGroupTravel = "slide" | "stretch"; type Size = "sm" | "md"; type GroupContextValue = { value: string; /** The radio the shared dot is on its way to; its own dot waits until it lands */ arriving: string | null; size: Size; register: (value: string, node: HTMLElement | null) => void; }; const GroupContext = createContext<GroupContextValue | null>(null); const radioGroupItemVariants = cva( [ "peer relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border border-input bg-transparent", "transition-colors duration-(--duration-fast) ease-out data-[state=checked]:border-primary data-[state=unchecked]:enabled:hover:border-ring", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "aria-invalid:border-destructive aria-invalid:outline-destructive", "disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)", ], { // The same sizes as Checkbox and Switch (16 and 20px) variants: { size: { sm: "size-4", md: "size-5" } }, defaultVariants: { size: "md" }, }, ); const dotSize = { sm: "size-1.5", md: "size-2" } as const; /** A radio's centre in the group, in layout pixels (offsets, so transforms and zoom don't count) */ function centreOf(node: HTMLElement, root: HTMLElement) { let x = node.offsetWidth / 2; let y = node.offsetHeight / 2; let element: HTMLElement | null = node; while (element && element !== root) { x += element.offsetLeft; y += element.offsetTop; element = element.offsetParent as HTMLElement | null; } return element === root ? { x, y } : null; } export type RadioGroupProps = ComponentProps<typeof RadioGroupPrimitive.Root> & { /** 16 or 20px radios */ size?: Size; /** How the dot travels to the new choice: straight across, or stretching on the way */ travel?: RadioGroupTravel; }; /** * One choice from a few, built on Radix Radio Group (role="radiogroup", arrow keys move and select). * Give each RadioGroupItem a visible label (`<label htmlFor>`). */ function RadioGroup({ className, size = "md", travel = "slide", value, defaultValue, onValueChange, children, ref, ...props }: RadioGroupProps) { const [uncontrolled, setUncontrolled] = useState(defaultValue ?? ""); const current = value ?? uncontrolled; const reduce = useReducedMotion() ?? false; const root = useRef<HTMLDivElement>(null); const items = useRef(new Map<string, HTMLElement>()); const [arriving, setArriving] = useState<string | null>(null); // The shared dot's diameter while it travels const [trip, setTrip] = useState<number | null>(null); // The shared dot: its back end (x, y), its length along the trip (width) and the trip's angle const x = useMotionValue(0); const y = useMotionValue(0); const width = useMotionValue(0); const angle = useMotionValue(0); const running = useRef<AnimationPlaybackControls[]>([]); const tripId = useRef(0); const previous = useRef(current); // Unmounting mid-trip stops the animations useEffect( () => () => { for (const animation of running.current) animation.stop(); }, [], ); // A new choice: send the shared dot from where it is (the old radio, or mid-trip) to the new radio // biome-ignore lint/correctness/useExhaustiveDependencies: only a change of choice starts a trip useLayoutEffect(() => { const from = previous.current; previous.current = current; if (from === current) return; const box = root.current; const start = items.current.get(from); const end = items.current.get(current); const a = box && start ? centreOf(start, box) : null; const b = box && end ? centreOf(end, box) : null; const shape = end?.querySelector<HTMLElement>("[data-slot=radio-group-indicator]"); for (const animation of running.current) animation.stop(); running.current = []; if (reduce || !a || !b || !shape) { setTrip(null); setArriving(null); return; } // Mid-trip, it leaves from where its front is now const size = shape.offsetWidth; const midTrip = trip !== null; const radians = (angle.get() * Math.PI) / 180; const origin = midTrip ? { x: x.get() + Math.cos(radians) * (width.get() - size), y: y.get() + Math.sin(radians) * (width.get() - size), } : a; const dx = b.x - origin.x; const dy = b.y - origin.y; const distance = Math.hypot(dx, dy); // Placed with jump(), not set(): set() counts the move from 0 (or the last trip) as speed, and the // slide's spring started with it, so the first trip overshot (owner, 2026-10-15). A slide turned // mid-trip keeps its speed, so it curves to the new radio smoothly. if (travel === "stretch" || !midTrip) { x.jump(origin.x); y.jump(origin.y); } width.jump(size); angle.jump((Math.atan2(dy, dx) * 180) / Math.PI); setTrip(size); setArriving(current); const id = ++tripId.current; let trips: AnimationPlaybackControls[]; if (travel === "stretch") { // The front leaves first and the back follows a moment later const ends = { front: 0, back: 0 }; const update = () => { x.set(origin.x + dx * ends.back); y.set(origin.y + dy * ends.back); width.set(size + Math.max(0, ends.front - ends.back) * distance); }; trips = [ animate(0, 1, { ...spring.snappy, onUpdate: (value) => { ends.front = value; update(); }, }), animate(0, 1, { ...spring.snappy, delay: duration.instant, onUpdate: (value) => { ends.back = value; update(); }, }), ]; } else { trips = [animate(x, b.x, spring.snappy), animate(y, b.y, spring.snappy)]; } running.current = trips; Promise.all(trips).then(() => { if (id !== tripId.current) return; running.current = []; setTrip(null); setArriving(null); }); }, [current]); return ( <GroupContext.Provider value={{ value: current, arriving, size, register: (name, node) => { if (node) items.current.set(name, node); else items.current.delete(name); }, }} > <RadioGroupPrimitive.Root ref={(node) => { root.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }} data-slot="radio-group" value={current} onValueChange={(next) => { if (value === undefined) setUncontrolled(next); onValueChange?.(next); }} className={cn("relative isolate grid gap-3", className)} {...props} > {children} {trip !== null ? ( // The shared dot: a zero-size point at its back end, turned towards the new radio, with the dot // (or, while stretching, the capsule) drawn out from it <motion.span aria-hidden data-slot="radio-group-travelling-dot" className="pointer-events-none absolute top-0 left-0 z-10 size-0" style={{ x, y, rotate: angle, originX: 0, originY: 0 }} > <motion.span className="absolute rounded-full bg-primary" style={{ left: -trip / 2, top: -trip / 2, height: trip, width }} /> </motion.span> ) : null} </RadioGroupPrimitive.Root> </GroupContext.Provider> ); } export type RadioGroupItemProps = ComponentProps<typeof RadioGroupPrimitive.Item>; /** One radio. Its dot shows while it's chosen, except while the shared dot is still on its way. */ function RadioGroupItem({ className, value, ref, ...props }: RadioGroupItemProps) { const group = useContext(GroupContext); const size = group?.size ?? "md"; const shown = group?.value === value && group.arriving !== value; return ( <RadioGroupPrimitive.Item ref={(node) => { group?.register(value, node); if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }} data-slot="radio-group-item" value={value} className={cn(radioGroupItemVariants({ size }), className)} {...props} > {/* Appears and goes at once, handing over to and from the shared dot */} <span aria-hidden data-slot="radio-group-indicator" className={cn( "pointer-events-none rounded-full bg-primary", dotSize[size], shown ? "scale-100" : "scale-0", )} /> </RadioGroupPrimitive.Item> ); } export { RadioGroup, RadioGroupItem, radioGroupItemVariants };Update the import paths to match your project.
Examples
In a row
Left and right arrow keys move along the row, and the dot follows.
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useId } from "react";
export default function RadioGroupHorizontal() {
const id = useId();
return (
<RadioGroup defaultValue="m" orientation="horizontal" aria-label="Size" className="flex flex-wrap gap-6">
{["S", "M", "L", "XL"].map((option) => (
<div key={option} className="flex items-center gap-2">
<RadioGroupItem id={`${id}-${option}`} value={option.toLowerCase()} />
<label htmlFor={`${id}-${option}`} className="text-sm">
{option}
</label>
</div>
))}
</RadioGroup>
);
}Cards
Each card is the radio's label, so the whole card picks it. Its border follows the choice.
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useId } from "react";
const plans = [
{ value: "starter", title: "Starter", price: "Free", note: "For trying things out." },
{ value: "pro", title: "Pro", price: "$12 a month", note: "For one person shipping." },
{ value: "team", title: "Team", price: "$40 a month", note: "For a small team." },
];
/** Each card is the radio's label, so a click anywhere on it picks the plan; the border follows the choice */
export default function RadioGroupCards() {
const id = useId();
return (
<RadioGroup defaultValue="pro" aria-label="Plan" className="w-full max-w-2xl gap-3 sm:grid-cols-3">
{plans.map((plan) => (
<label
key={plan.value}
htmlFor={`${id}-${plan.value}`}
className="grid cursor-pointer gap-1 rounded-lg border border-input bg-background p-4 transition-colors duration-(--duration-fast) ease-out has-data-[state=checked]:border-primary"
>
<span className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{plan.title}</span>
<RadioGroupItem id={`${id}-${plan.value}`} value={plan.value} />
</span>
<span className="text-sm">{plan.price}</span>
<span className="text-xs text-muted-foreground">{plan.note}</span>
</label>
))}
</RadioGroup>
);
}Disabled
One option can't be picked; arrow keys skip it.
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useId } from "react";
const options = [
{ value: "email", label: "Email" },
{ value: "sms", label: "Text message", disabled: true, hint: "(not available in your region)" },
{ value: "app", label: "Authenticator app" },
];
export default function RadioGroupDisabled() {
const id = useId();
return (
<RadioGroup defaultValue="email" aria-label="Two-step sign-in">
{options.map((option) => (
<div key={option.value} className="flex items-center gap-3">
<RadioGroupItem id={`${id}-${option.value}`} value={option.value} disabled={option.disabled} />
<label
htmlFor={`${id}-${option.value}`}
className={option.disabled ? "text-sm text-muted-foreground" : "text-sm"}
>
{option.label} {option.hint}
</label>
</div>
))}
</RadioGroup>
);
}API reference
RadioGroup
Also accepts every Radix Radio Group prop. Arrow keys move the choice; Tab leaves the group.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | string | – | The chosen value, when you control it. |
defaultValue? | string | – | The value chosen at first. |
onValueChange? | (value: string) => void | – | Called when the user picks another option. |
travel? | "slide" | "stretch" | "slide" | How the dot travels to the new choice: straight across, or stretching on the way. |
size? | "sm" | "md" | "md" | 16 or 20px radios, the sizes of Checkbox and Switch. |
orientation? | "horizontal" | "vertical" | – | Which arrow keys move the choice. Both work when it's left out. |
disabled? | boolean | false | Prevents interaction with every option. |
required? | boolean | false | A choice is needed to submit the form. |
name? | string | – | Submits the chosen value with a form. |
RadioGroupItem
One radio. Give it an id and a visible label with htmlFor.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | The value this radio picks. |
disabled? | boolean | false | Prevents picking this option. |
Related components
An accessible React checkbox for Next.js forms, with a check that draws itself in and an indeterminate state, built on Radix Checkbox.
An accessible React segmented control for Next.js: one choice from a few options, with a pill that slides to the one you pick.
An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.