Components · Forms
Radio group
Native radios in a fieldset, with hints: one Tab stop, arrow keys choose. No JavaScript.
1.4% of the 2,062 sites in The UI Index have a radio (28 sites).
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ radio-group.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ radio-group.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS.
import type { ChangeEvent } from 'react';
import { cn } from '@/lib/utils';
import { radioGroup, type RadioOption } from './radio-group.variants';
export type { RadioOption };
export interface RadioGroupProps {
/** The group's name (the form field) and, with the option value, the radios' ids. */
name: string;
legend: string;
options: RadioOption[];
/** Controlled value (with onChange); use defaultValue for an uncontrolled group. */
value?: string;
defaultValue?: string;
onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
/** Prefix for element ids; defaults to the name. */
id?: string;
className?: string;
}
/** Native radios in a fieldset: one Tab stop, arrow keys move and select. */
export function RadioGroup({ name, legend, options, value, defaultValue, onChange, id = name, className }: RadioGroupProps) {
return (
<fieldset className={cn(radioGroup.root, className)}>
<legend className={radioGroup.legend}>{legend}</legend>
{options.map((o) => (
<div key={o.value} className={radioGroup.option}>
<input
type="radio"
id={`${id}-${o.value}`}
name={name}
value={o.value}
{...(value !== undefined ? { checked: o.value === value, onChange: (e: ChangeEvent<HTMLInputElement>) => onChange?.(o.value, e) } : { defaultChecked: o.value === defaultValue, onChange: onChange && ((e: ChangeEvent<HTMLInputElement>) => onChange(o.value, e)) })}
disabled={o.disabled}
aria-describedby={o.hint ? `${id}-${o.value}-hint` : undefined}
className={radioGroup.radio}
/>
<label htmlFor={`${id}-${o.value}`} className={radioGroup.label}>
{o.label}
{o.hint && <span id={`${id}-${o.value}-hint`} className={radioGroup.hint}>{o.hint}</span>}
</label>
</div>
))}
</fieldset>
);
}/** Native radio buttons in a fieldset: arrow keys move and select within the group, one Tab stop, no JavaScript. */
export const radioGroup = {
root: 'm-0 grid min-w-0 gap-3 border-0 p-0',
legend: 'mb-1 p-0 text-sm font-medium',
option: 'flex items-start gap-3 text-sm',
radio:
"peer mt-0.5 grid size-4 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border border-input bg-background transition-[border-color,transform] before:size-2 before:scale-0 before:rounded-full before:bg-primary before:transition-transform before:content-[''] enabled:not-checked:hover:border-foreground enabled:not-checked:hover:bg-accent enabled:active:scale-90 enabled:not-checked:active:bg-pressed checked:enabled:hover:before:scale-[0.8] checked:enabled:active:border-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:scale-100 checked:border-primary checked:before:scale-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
label: 'cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
hint: 'block text-muted-foreground',
};
export type RadioOption = { value: string; label: string; hint?: string; disabled?: boolean };---
import { cn } from '@/lib/utils';
import { radioGroup, type RadioOption } from './radio-group.variants';
interface Props {
/** The group's name (the form field) and, with the option value, the radios' ids. */
name: string;
legend: string;
options: RadioOption[];
value?: string;
/** Prefix for element ids; defaults to the name. */
id?: string;
class?: string;
}
const { name, legend, options, value, id = name, class: className } = Astro.props;
---
<fieldset class={cn(radioGroup.root, className)}>
<legend class={radioGroup.legend}>{legend}</legend>
{
options.map((o) => (
<div class={radioGroup.option}>
<input type="radio" id={`${id}-${o.value}`} name={name} value={o.value} checked={o.value === value} disabled={o.disabled} aria-describedby={o.hint ? `${id}-${o.value}-hint` : undefined} class={radioGroup.radio} />
<label for={`${id}-${o.value}`} class={radioGroup.label}>
{o.label}
{o.hint && <span id={`${id}-${o.value}-hint`} class={radioGroup.hint}>{o.hint}</span>}
</label>
</div>
))
}
</fieldset>/** Native radio buttons in a fieldset: arrow keys move and select within the group, one Tab stop, no JavaScript. */
export const radioGroup = {
root: 'm-0 grid min-w-0 gap-3 border-0 p-0',
legend: 'mb-1 p-0 text-sm font-medium',
option: 'flex items-start gap-3 text-sm',
radio:
"peer mt-0.5 grid size-4 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border border-input bg-background transition-[border-color,transform] before:size-2 before:scale-0 before:rounded-full before:bg-primary before:transition-transform before:content-[''] enabled:not-checked:hover:border-foreground enabled:not-checked:hover:bg-accent enabled:active:scale-90 enabled:not-checked:active:bg-pressed checked:enabled:hover:before:scale-[0.8] checked:enabled:active:border-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:scale-100 checked:border-primary checked:before:scale-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
label: 'cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
hint: 'block text-muted-foreground',
};
export type RadioOption = { value: string; label: string; hint?: string; disabled?: boolean };<fieldset class="m-0 grid min-w-0 gap-3 border-0 p-0 w-full max-w-sm">
<legend class="mb-1 p-0 text-sm font-medium">Deploy</legend>
<div class="flex items-start gap-3 text-sm">
<input type="radio" id="ex-deploy-push" name="ex-deploy" value="push" checked aria-describedby="ex-deploy-push-hint" class="peer mt-0.5 grid size-4 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border border-input bg-background transition-[border-color,transform] before:size-2 before:scale-0 before:rounded-full before:bg-primary before:transition-transform before:content-[''] enabled:not-checked:hover:border-foreground enabled:not-checked:hover:bg-accent enabled:active:scale-90 enabled:not-checked:active:bg-pressed checked:enabled:hover:before:scale-[0.8] checked:enabled:active:border-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:scale-100 checked:border-primary checked:before:scale-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50">
<label for="ex-deploy-push" class="cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
On every push
<span id="ex-deploy-push-hint" class="block text-muted-foreground">Each commit to main goes live.</span>
</label>
</div>
<div class="flex items-start gap-3 text-sm">
<input type="radio" id="ex-deploy-tag" name="ex-deploy" value="tag" aria-describedby="ex-deploy-tag-hint" class="peer mt-0.5 grid size-4 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border border-input bg-background transition-[border-color,transform] before:size-2 before:scale-0 before:rounded-full before:bg-primary before:transition-transform before:content-[''] enabled:not-checked:hover:border-foreground enabled:not-checked:hover:bg-accent enabled:active:scale-90 enabled:not-checked:active:bg-pressed checked:enabled:hover:before:scale-[0.8] checked:enabled:active:border-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:scale-100 checked:border-primary checked:before:scale-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50">
<label for="ex-deploy-tag" class="cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
On a release tag
<span id="ex-deploy-tag-hint" class="block text-muted-foreground">Only tags like v1.2.0.</span>
</label>
</div>
<div class="flex items-start gap-3 text-sm">
<input type="radio" id="ex-deploy-manual" name="ex-deploy" value="manual" aria-describedby="ex-deploy-manual-hint" class="peer mt-0.5 grid size-4 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border border-input bg-background transition-[border-color,transform] before:size-2 before:scale-0 before:rounded-full before:bg-primary before:transition-transform before:content-[''] enabled:not-checked:hover:border-foreground enabled:not-checked:hover:bg-accent enabled:active:scale-90 enabled:not-checked:active:bg-pressed checked:enabled:hover:before:scale-[0.8] checked:enabled:active:border-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:scale-100 checked:border-primary checked:before:scale-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50">
<label for="ex-deploy-manual" class="cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
By hand
<span id="ex-deploy-manual-hint" class="block text-muted-foreground">From the dashboard.</span>
</label>
</div>
<div class="flex items-start gap-3 text-sm">
<input type="radio" id="ex-deploy-schedule" name="ex-deploy" value="schedule" disabled aria-describedby="ex-deploy-schedule-hint" class="peer mt-0.5 grid size-4 shrink-0 cursor-pointer appearance-none place-content-center rounded-full border border-input bg-background transition-[border-color,transform] before:size-2 before:scale-0 before:rounded-full before:bg-primary before:transition-transform before:content-[''] enabled:not-checked:hover:border-foreground enabled:not-checked:hover:bg-accent enabled:active:scale-90 enabled:not-checked:active:bg-pressed checked:enabled:hover:before:scale-[0.8] checked:enabled:active:border-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] checked:enabled:active:before:scale-100 checked:border-primary checked:before:scale-100 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50">
<label for="ex-deploy-schedule" class="cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-50">
On a schedule
<span id="ex-deploy-schedule-hint" class="block text-muted-foreground">On the Team plan.</span>
</label>
</div>
</fieldset>