ControlsRadio Group
One choice among a few — roving focus and a scaling dot, on the Radix radio-group.
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."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 };