ControlsRadio Group

One choice among a few — roving focus and a scaling dot, on the Radix radio-group.

Preview

The thinking

A radio group exists for the one case a checkbox can't cover: exactly one choice out of a few, where picking a new option un-picks the last for you. So I built almost nothing of my own — I wrap RadioGroupPrimitive.Root and let Radix carry the contract, because the load-bearing decision here is to not reinvent the keyboard model — that contract is the thing custom radios most often break, so I refuse to own it. My job was the last two pixels: the indicator is forceMounted so the dot can scale through a transition instead of popping into existence at the moment of selection. That's the difference between a control that feels built and one that feels defaulted — the choice lands, it doesn't appear.

Interface design

At rest the control is almost not there: an h-4 w-4 circle, a border-input hairline, bg-transparent — no fill, because I want the unchosen options to recede rather than audition. Selection recolors instead of redrawing: data-[state=checked]:border-primary pulls the ring to the accent, and the dot is h-2 w-2 of bg-primary, exactly half the well's diameter, so a checked radio reads at arm's length without a glyph. The root contributes grid gap-3 and nothing else — layout is the composer's job. The class I'd defend hardest is shrink-0: in a flex row beside a long label, it's the difference between a circle and an egg.

Interaction

The whole group is one tab stop: Radix gives it roving tabindex, so Tab lands on the current choice and arrow keys move and select in one gesture — that's the native radio contract, and breaking it is how custom radios usually fail. The intent-reading here is Radix's, not mine: an arrow press treats moving and choosing as the same intent, so the keyboard never pays twice for one decision. The dot scales from scale-0 to scale-100 over duration-150 on ease-swift, transform only, and the border recolors on the same clock — selection is a microinteraction, not a reveal. The indicator stays force-mounted so that scale can transition instead of popping at mount.

Sound design

One short note, and none of it lives here: Radix gives every item role="radio", so the SoundLayer's capturing click listener covers the group with zero audio code of my own. No pair, deliberately — a radio can't be un-chosen, so there is no off state to give a falling note to. Only toggles earn the two-tone.

Empathy

Disabled fades the item to disabled:opacity-50 and swaps the cursor to disabled:cursor-not-allowed, so the pointer hears no before the click does; and because the disabled attribute lands on a real button, the SoundLayer's :disabled check keeps it silent too. Focus draws focus-visible:ring-1 on the ring token for the keyboard and never for the mouse. Under reduced motion, motion-reduce:transition-none sits on both the item and the dot, so the choice snaps instead of scaling — the state survives, only the travel goes. Screen readers get the native contract from Radix: role="radiogroup" on the root, role="radio" and aria-checked on every item, which is why the demo only adds an aria-label and pairs each circle with a label's htmlFor. The item ships no text of its own; naming each option is the composer's job, and I left it that way on purpose.
radio-group.tsx
"use client";

import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";

import { cn } from "@/lib/utils";

// sound is delegated: radix gives every item role="radio", so the
// SoundLayer's tap covers selection. no pair — a radio can't be
// un-chosen, and only toggles earn the two-tone.

export interface RadioGroupProps
	extends React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> {
	ref?: React.Ref<React.ComponentRef<typeof RadioGroupPrimitive.Root>>;
}

const RadioGroup = ({ ref, className, ...props }: RadioGroupProps) => (
	<RadioGroupPrimitive.Root
		ref={ref}
		className={cn("grid gap-3", className)}
		{...props}
	/>
);
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;

export interface RadioGroupItemProps
	extends React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> {
	ref?: React.Ref<React.ComponentRef<typeof RadioGroupPrimitive.Item>>;
}

const RadioGroupItem = ({ ref, className, ...props }: RadioGroupItemProps) => (
	<RadioGroupPrimitive.Item
		ref={ref}
		className={cn(
			"group aspect-square h-4 w-4 shrink-0 rounded-full border border-input bg-transparent transition-colors duration-150 ease-swift focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary motion-reduce:transition-none",
			className
		)}
		{...props}
	>
		{/* force-mounted so the dot can scale through a transition — 150ms,
		    transform only: selection is a microinteraction, not a reveal */}
		<RadioGroupPrimitive.Indicator
			forceMount
			className="flex h-full w-full items-center justify-center"
		>
			<span className="h-2 w-2 scale-0 rounded-full bg-primary transition-transform duration-150 ease-swift group-data-[state=checked]:scale-100 motion-reduce:transition-none" />
		</RadioGroupPrimitive.Indicator>
	</RadioGroupPrimitive.Item>
);
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;

export { RadioGroup, RadioGroupItem };