Radio Group
Native radios in a radiogroup fieldset, three sizes and choice cards. The dot glides to the chosen option, all in CSS.
Installation
Add the registry to components.json, once
{
"registries": {
"@rabtx": "http://localhost:3002/r/{name}.json"
}
}Add the component
npx shadcn@latest add @rabtx/radio-groupOr, without the namespace, by URL:
npx shadcn@latest add http://localhost:3002/r/radio-group.jsonThe files land in your components/ui and are styled by your own theme. Add --overwrite to replace a shadcn component with the same name.
Usage
Installed from the registry, import from @/components/ui/radio-group; inside this monorepo, from
@rabtx/ui/radio-group.
import { RadioGroup, RadioGroupItem } from "@rabtx/ui/radio-group";
export function Plan() {
const [plan, setPlan] = useState("pro");
return (
<RadioGroup aria-label="Plan" value={plan} onValueChange={setPlan}>
<RadioGroupItem value="starter">Starter</RadioGroupItem>
<RadioGroupItem value="pro">Pro</RadioGroupItem>
</RadioGroup>
);
}Every option is a real <input type="radio"> and they share one name (generated when you leave
it out), inside a <fieldset role="radiogroup">. So the browser does the hard parts: the group is
one tab stop, the arrow keys move the choice (and wrap), Space chooses the focused option, forms
submit the value and screen readers announce "2 of 3". Each option wraps its circle in a <label>,
so the text names it and the whole row is clickable. Name the group with aria-label,
aria-labelledby or a Field.
Description
description adds a quieter line under an option's label. It is linked with aria-describedby,
so it is read as the description rather than as part of the name.
Horizontal
orientation="horizontal" lays the options out in a row that wraps. The arrow keys work the same
way in both orientations.
Choice cards
variant="card" draws every option as a bordered card with the circle at the end, for plans,
shipping speeds and similar. The whole card is the label: hover darkens its edge, the chosen card
takes a --primary edge and tint, keyboard focus rings the card and pressing springs it down a
little. Cards take every size and both orientations.
States
Chosen and not, a disabled option (disabled on the item) and a disabled group (disabled on the
group disables the fieldset, so every option at once). Inside a Field, the Field's label names the
group and error sets aria-invalid, links the message and turns the circles red until something
is chosen.
Choose a shipping speed to continue.
Controlled
Value: system
API
RadioGroup takes everything a <fieldset> takes, plus:
Prop
Type
RadioGroupItem takes everything a native radio takes, except type, name and checked, plus:
Prop
Type
Outside React, the class names are exported from @rabtx/ui/radio-group/styles
(radioGroupClass(), radioGroupParts):
<fieldset role="radiogroup" class="rx-radio-group rx-radio-group-md" aria-label="Plan">
<label class="rx-radio-item">
<span class="rx-radio">
<input type="radio" name="plan" value="pro" class="rx-radio-input" checked />
</span>
<span class="rx-radio-text"><span>Pro</span></span>
</label>
</fieldset>Theming
The Radio Group reads only the theme tokens.
| Part | Tokens |
|---|---|
| Circle, off | fill --background, edge --muted-foreground at 50% (full on hover) |
| Circle, on | edge and dot --primary |
| Focus and error | --ring, --destructive (edge, and a 15% halo) |
| Label and description | --foreground, --muted-foreground |
| Cards | fill --card, edge --input; chosen --primary edge and a 6% --primary tint; corners --radius-control |
| Motion | --spring-glide (the dot travelling), --spring-swap (the dot growing), --spring-press (press), --motion-ease (colors, exit) |
How the motion works
| Effect | CSS |
|---|---|
| State | each input is invisible and laid over its circle; the circle reads it with :has(> :checked), :has(> :focus-visible), :has(> :disabled); the group reads aria-invalid and :has(:checked) |
| Glide | where anchor positioning exists, the dot is the group's ::after, anchored (position-anchor) to whichever circle is checked; top and left are anchor(center) and transition on the new glide spring (360 / 32 / 0.6, no overshoot, about 420ms), so the dot travels to the new choice. anchor-scope keeps each group's anchor to itself |
| First choice | while nothing is chosen the anchor is the first circle and the dot is hidden at scale: 0; choosing grows it on the swap spring while it glides over |
| Fallback | without anchor positioning, each circle's own ::after dot pops in on the swap spring and shrinks out in 160ms |
| Press | :active on a circle or its label springs the circle to scale: 0.92 on the press spring; a card presses as a whole to 0.985 |
Accessibility
- Native radios: the radiogroup role, one tab stop, arrow keys, Space, form submission and the "n of m" announcement come from the browser.
- Keyboard focus shows a 2px ring on the circle, or on the whole card. The click target is at least 24px.
- Disabled options are half opacity and are skipped by the arrow keys.
- With reduced motion there is no press, pop or glide; the dot fades in 100ms. With
data-motion="off"every state changes at once.
Switch
A native checkbox drawn as a switch. Pressing stretches the thumb toward where it is going; releasing springs it across. Every animation is CSS.
Slider
Native range inputs as a slider or a range. The thumb grows on a spring when pressed and a bubble follows it with the value. Every animation is CSS.