Radio
Exclusive selections. Clear visual state. Prevent user errors.
1import { Radio } from '@/components/ui/radio';23const cycles = [4 {5 value: 'monthly',6 title: 'Monthly',7 detail: '$29 per user, cancel any time',8 defaultChecked: false,9 },10 {11 value: 'yearly',12 title: 'Yearly',13 detail: '$24 per user, two months free',14 defaultChecked: true,15 },16];1718export function Default() {19 return (20 <div className="flex w-full max-w-sm flex-col gap-2">21 {cycles.map((cycle) => (22 <label23 key={cycle.value}24 className="border-border bg-card flex cursor-pointer items-center gap-3 rounded-lg border p-3"25 >26 <Radio name="billing-cycle" value={cycle.value} defaultChecked={cycle.defaultChecked} />27 <span className="flex flex-col gap-0.5">28 <span className="text-sm font-medium">{cycle.title}</span>29 <span className="text-muted-foreground text-xs">{cycle.detail}</span>30 </span>31 </label>32 ))}33 </div>34 );35}
Installation
Copy and paste the following code into your project.
1'use client';23import * as React from 'react';4import { cn } from '../lib/cn';56export interface RadioProps extends React.InputHTMLAttributes<HTMLInputElement> {7 onCheckedChange?: (checked: boolean) => void;8 inputClassName?: string;9}1011function Radio({12 className,13 inputClassName,14 onCheckedChange,15 onChange,16 ref,17 ...props18}: RadioProps & { ref?: React.Ref<HTMLInputElement> }) {19 const innerRef = React.useRef<HTMLInputElement>(null);2021 const mergedRef = React.useCallback(22 (node: HTMLInputElement | null) => {23 innerRef.current = node;24 if (typeof ref === 'function') ref(node);25 else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;26 },27 [ref],28 );2930 return (31 <div32 role="presentation"33 className={cn(34 "relative flex shrink-0 items-center justify-center before:absolute before:-inset-2 before:content-[''] pointer-coarse:before:-inset-3.5",35 className,36 )}37 onClick={(event) => {38 if (event.target !== event.currentTarget) return;39 if (event.currentTarget.closest('label')) return;40 innerRef.current?.click();41 }}42 >43 <input44 type="radio"45 ref={mergedRef}46 className={cn(47 'peer focus-visible:ring-ring border-primary checked:border-primary checked:bg-primary bg-background ring-offset-background relative size-4 shrink-0 cursor-pointer appearance-none rounded-full border transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 forced-colors:checked:border-[Highlight]',48 inputClassName,49 )}50 onChange={(e) => {51 onChange?.(e);52 onCheckedChange?.(e.target.checked);53 }}54 {...props}55 />56 <div57 aria-hidden="true"58 data-slot="radio-indicator"59 className="bg-background pointer-events-none absolute size-1.5 rounded-full opacity-0 transition-opacity peer-checked:opacity-100 forced-colors:bg-[Highlight] forced-colors:forced-color-adjust-none"60 />61 </div>62 );63}64Radio.displayName = 'Radio';6566export { Radio };
Update the import paths to match your project structure.
Anatomy
1import { Radio } from '@/components/ui/radio';
1<Radio id="option-one" name="radio-group" />
Variants
Default
1import { Radio } from '@/components/ui/radio';23const cycles = [4 {5 value: 'monthly',6 title: 'Monthly',7 detail: '$29 per user, cancel any time',8 defaultChecked: false,9 },10 {11 value: 'yearly',12 title: 'Yearly',13 detail: '$24 per user, two months free',14 defaultChecked: true,15 },16];1718export function Default() {19 return (20 <div className="flex w-full max-w-sm flex-col gap-2">21 {cycles.map((cycle) => (22 <label23 key={cycle.value}24 className="border-border bg-card flex cursor-pointer items-center gap-3 rounded-lg border p-3"25 >26 <Radio name="billing-cycle" value={cycle.value} defaultChecked={cycle.defaultChecked} />27 <span className="flex flex-col gap-0.5">28 <span className="text-sm font-medium">{cycle.title}</span>29 <span className="text-muted-foreground text-xs">{cycle.detail}</span>30 </span>31 </label>32 ))}33 </div>34 );35}
With Label
Radio component alongside a label for context. The
name attribute is used to group the options.Send activity email
One email per event.
Everything from the last 24 hours, at 8:00.
Monday mornings only.
1import { Label } from '@/components/ui/label';2import { Radio } from '@/components/ui/radio';34const frequencies = [5 {6 id: 'digest-realtime',7 label: 'Right away',8 hint: 'One email per event.',9 defaultChecked: false,10 },11 {12 id: 'digest-daily',13 label: 'Daily digest',14 hint: 'Everything from the last 24 hours, at 8:00.',15 defaultChecked: true,16 },17 {18 id: 'digest-weekly',19 label: 'Weekly summary',20 hint: 'Monday mornings only.',21 defaultChecked: false,22 },23];2425export function WithLabel() {26 return (27 <div className="flex w-full max-w-md flex-col gap-4">28 <p className="text-sm font-medium">Send activity email</p>29 {frequencies.map((frequency) => (30 <div key={frequency.id} className="flex items-start gap-2.5">31 <Radio32 id={frequency.id}33 name="digest-frequency"34 defaultChecked={frequency.defaultChecked}35 className="mt-0.5"36 />37 <div className="flex flex-col gap-1">38 <Label htmlFor={frequency.id}>{frequency.label}</Label>39 <span className="text-muted-foreground text-xs">{frequency.hint}</span>40 </div>41 </div>42 ))}43 </div>44 );45}
Disabled
A disabled state stops interactions.
Data region
Current region, set when the workspace was created.
Moving regions requires a support request.
1import { Label } from '@/components/ui/label';2import { Radio } from '@/components/ui/radio';34const regions = [5 {6 id: 'region-us',7 label: 'US East, Virginia',8 hint: 'Current region, set when the workspace was created.',9 defaultChecked: true,10 },11 {12 id: 'region-eu',13 label: 'Europe, Frankfurt',14 hint: 'Moving regions requires a support request.',15 defaultChecked: false,16 },17];1819export function Disabled() {20 return (21 <div className="flex w-full max-w-md flex-col gap-4">22 <p className="text-sm font-medium">Data region</p>23 {regions.map((region) => (24 <div key={region.id} className="flex items-start gap-2.5">25 <Radio26 id={region.id}27 name="data-region"28 defaultChecked={region.defaultChecked}29 className="mt-0.5"30 disabled31 />32 <div className="flex flex-col gap-1">33 <Label htmlFor={region.id} className="opacity-40">34 {region.label}35 </Label>36 <span className="text-muted-foreground text-xs">{region.hint}</span>37 </div>38 </div>39 ))}40 </div>41 );42}
API Reference
Radio
| Prop | Type | Default | Description |
|---|---|---|---|
onCheckedChange | (checked: boolean) => void | - | Callback when checked state changes |
disabled | boolean | - | Disables the radio |
name | string | - | Name of the radio group |
className | string | - | Classes for the wrapper, for layout and spacing |
inputClassName | string | - | Classes for the input itself, for example size-5 to make it bigger |
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.