React Radio Group Component Examples
Copy-paste React Radio Group examples: labeled options, controlled state, plan picker, square variant, and react-hook-form. Zero runtime deps. TypeScript only.
A radio group earns its place whenever exactly one option out of a short, fixed list has to be chosen — a billing plan, a shipping speed, a notification channel, a single-answer survey question. Drivn's Radio Group covers that in one file at @/components/ui/radio-group.tsx, importing react and the local cn() helper and nothing else. The root puts role="radiogroup" on a flex flex-col gap-3 container and holds the selection in a React.useState seeded from defaultValue. Each item is a <label> wrapping an sr-only native <input type="radio"> plus a <span> that draws the visible circle, so keyboard focus and the peer-focus-visible:ring-[3px] outline come from the browser rather than from custom key handling.
Everything below is a call site you can paste. Each snippet imports from @/components/ui/radio-group — the path the Drivn CLI writes on install — and uses the same RadioGroup / RadioGroup.Item dot-notation pair. All of it is TypeScript, because that is the only thing Drivn ships.
Five shapes follow: an uncontrolled labeled group, a controlled group whose value drives neighbouring UI, a plan picker built on the description prop, a horizontal row in the square variant, and a react-hook-form field validated by zod. Props live on the Radio Group docs page, and the Radix-backed alternative is broken down on Drivn vs shadcn Radio Group.
Default labeled group
The shortest group that does something useful. defaultValue on the root pre-selects an option; each RadioGroup.Item needs a unique value and a label. Nothing here is controlled — the root keeps the selection in its own React.useState, and the onSelect callback it puts on context updates that state when an item's hidden input fires onChange. The item renders a <label> around both the input and the text-sm font-medium text, so clicking the word "SMS" selects it with no htmlFor or id wiring anywhere in the tree.
This is the right starting shape for a settings row (notification channel, theme preference), a short single-select question, and anything whose value the surrounding page does not need mid-interaction. One detail worth knowing before you drop it in a plain <form>: RadioGroup.Item consumes its value prop rather than forwarding it to the native input, so the checked radio does not carry a useful value through FormData. Read the selection through React instead — onValueChange on the root, which the next example covers. When more than one option can be active at once, the Drivn Checkbox is the component you want.
1 import { RadioGroup } from "@/components/ui/radio-group" 2 3 export default function NotifyChannel() { 4 return ( 5 <RadioGroup defaultValue="email"> 6 <RadioGroup.Item value="email" label="Email" /> 7 <RadioGroup.Item value="sms" label="SMS" /> 8 <RadioGroup.Item value="push" label="Push" /> 9 </RadioGroup> 10 ) 11 }
Controlled radio group
When the selected value needs to drive surrounding UI — show a different panel per option, enable a button, recompute a price — lift the state into the parent with React.useState and pass value plus onValueChange to the root. The component becomes fully controlled: it renders whatever value the parent holds and reports every change through the callback, so the parent stays the single source of truth. The example below mirrors the selected channel into a line of text, but the same pattern feeds a conditional render, a fetch, or a derived calculation.
The 'use client' directive on this file is necessary because the call site itself runs React.useState — the Radio Group component does not force the boundary, the surrounding component does. onValueChange receives the new value string directly, so there is no event.target unwrapping. Controlled mode is also what the react-hook-form example further down relies on. For a value that the parent only reads at submit time and never mid-interaction, the uncontrolled default group above is the lighter choice.
1 'use client' 2 import * as React from 'react' 3 import { RadioGroup } from "@/components/ui/radio-group" 4 5 export default function ShippingSpeed() { 6 const [speed, setSpeed] = React.useState('standard') 7 8 return ( 9 <div className="space-y-3"> 10 <RadioGroup value={speed} onValueChange={setSpeed}> 11 <RadioGroup.Item value="standard" label="Standard (5 days)" /> 12 <RadioGroup.Item value="express" label="Express (2 days)" /> 13 <RadioGroup.Item value="overnight" label="Overnight" /> 14 </RadioGroup> 15 <p className="text-sm text-muted-foreground"> 16 Selected: {speed} 17 </p> 18 </div> 19 ) 20 }
Plan picker with descriptions
For a billing plan picker or any option list where each choice needs a supporting line, pass the description prop alongside label. The component stacks the description as a text-sm text-muted-foreground line under the bold label, and the whole two-line block stays inside the clickable <label> so tapping anywhere in the option selects it. This is the cleanest shape for plan tiers, permission levels, and shipping options where the price or detail belongs right under the name.
To turn each option into a bordered card tile, wrap the items in a Drivn Card or pass a className with border and padding utilities through RadioGroup.Item — the className merges via cn() onto the item wrapper. The example below keeps the plain two-line layout. When the built-in label-plus-description block is not enough — say you need a price badge aligned to the right — pass children instead of label/description and render any JSX you want; the component drops the built-in block and renders your content next to the radio. Pair the picker with a Drivn Badge for a "Most popular" marker.
1 import { RadioGroup } from "@/components/ui/radio-group" 2 3 export default function PlanPicker() { 4 return ( 5 <RadioGroup defaultValue="startup"> 6 <RadioGroup.Item 7 value="startup" 8 label="Startup" 9 description="Up to 5 team members, 10 GB storage" 10 /> 11 <RadioGroup.Item 12 value="business" 13 label="Business" 14 description="Up to 25 team members, 100 GB storage" 15 /> 16 <RadioGroup.Item 17 value="enterprise" 18 label="Enterprise" 19 description="Unlimited members, unlimited storage" 20 /> 21 </RadioGroup> 22 ) 23 }
Horizontal layout with the square variant
For a compact option row — a yes/no choice, a size selector, a short rating scale — pass orientation="horizontal" on the root. The component swaps its flex container from a column to a row (flex-row gap-4), so the options sit side by side instead of stacked. Pair it with variant="square" to render the radios with a rounded-[4px] frame and a rounded-[2px] inner dot instead of the default circle. A square radio reads as a distinct control while still signalling single-select, which helps in dense panels where circle and square shapes carry meaning.
The variant and orientation props are both read once on the root and shared to every item through context — there is no per-item override, which keeps a group visually consistent. The example below renders a horizontal square group for a t-shirt size picker. Horizontal layout works best with short labels; once an option needs a description line, the vertical default reads more cleanly. For longer option lists that would overflow a row, keep the vertical orientation or switch to the Drivn Select dropdown.
1 import { RadioGroup } from "@/components/ui/radio-group" 2 3 export default function SizePicker() { 4 return ( 5 <RadioGroup 6 defaultValue="m" 7 orientation="horizontal" 8 variant="square" 9 > 10 <RadioGroup.Item value="s" label="Small" /> 11 <RadioGroup.Item value="m" label="Medium" /> 12 <RadioGroup.Item value="l" label="Large" /> 13 <RadioGroup.Item value="xl" label="X-Large" /> 14 </RadioGroup> 15 ) 16 }
Validated radio group with react-hook-form
For a radio group inside a real form, wire it to react-hook-form with a Controller and validate the choice with zod. The zod schema marks the field required so a submit with no option selected surfaces an error message. The Controller bridges the form library to the controlled value / onValueChange shape the Radio Group already exposes — field.value flows into value and field.onChange flows into onValueChange, with no adapter code in between.
This is the production shape for a checkout step, a survey question, or any single-select field that must be answered before the form can submit. The zodResolver connects the schema to the form, and the error string renders under the group when validation fails. Because the Radio Group already speaks the controlled-input contract, the Controller wrapper is the only glue needed — the same pattern used for the Drivn Select and Checkbox in forms. See Drivn vs shadcn Radio Group for how the native-input underpinning compares with the Radix-backed alternative.
1 'use client' 2 import { useForm, Controller } from 'react-hook-form' 3 import { zodResolver } from '@hookform/resolvers/zod' 4 import { z } from 'zod' 5 import { RadioGroup } from "@/components/ui/radio-group" 6 import { Button } from "@/components/ui/button" 7 8 const schema = z.object({ 9 plan: z.string().min(1, 'Pick a plan to continue'), 10 }) 11 12 export default function PlanForm() { 13 const { control, handleSubmit, formState } = useForm({ 14 resolver: zodResolver(schema), 15 defaultValues: { plan: '' }, 16 }) 17 18 return ( 19 <form 20 onSubmit={handleSubmit((d) => console.log(d))} 21 className="space-y-3" 22 > 23 <Controller 24 name="plan" 25 control={control} 26 render={({ field }) => ( 27 <RadioGroup 28 value={field.value} 29 onValueChange={field.onChange} 30 > 31 <RadioGroup.Item value="startup" label="Startup" /> 32 <RadioGroup.Item value="business" label="Business" /> 33 </RadioGroup> 34 )} 35 /> 36 {formState.errors.plan && ( 37 <p className="text-sm text-destructive"> 38 {formState.errors.plan.message} 39 </p> 40 )} 41 <Button type="submit">Continue</Button> 42 </form> 43 ) 44 }
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
The component file ships with 'use client' because it calls React.useState to hold the selected value when used uncontrolled. The call site needs its own directive only when it owns interactive state — the controlled and react-hook-form examples both add 'use client' because the surrounding component runs useState or form hooks. The default and plan-picker examples are uncontrolled, so the group manages its own state and the call site does not add anything extra.
Add a name prop to each RadioGroup.Item. The component renders a real <input type="radio"> behind every option, and the name is spread onto that input through the forwarded InputHTMLAttributes. With a shared name, the browser groups the radios and the selected value appears in FormData on submit — no hidden mirror field needed. This is the practical payoff of Drivn building the group on native inputs rather than Radix buttons.
Pass the description prop alongside label on RadioGroup.Item. The component renders the description as a text-sm text-muted-foreground line stacked under the bold label, and the whole two-line block stays inside the clickable <label> element so a tap anywhere selects the option. When the built-in two-line layout is not enough — for example a price badge aligned right — pass children instead and render any JSX you want next to the radio.
Yes. Pass orientation="horizontal" on the RadioGroup root. The component swaps its flex container from a column to a row, so the options sit side by side. Horizontal layout suits short labels — a yes/no choice or a size picker. Once an option needs a description line, the vertical default (the root's standard flex flex-col gap-3) reads more cleanly. The orientation is read once on the root and shared to every item.
Pass variant="square" on the RadioGroup root. The component swaps the outer radio frame to rounded-[4px] and the inner selected dot to rounded-[2px] so the dot shape matches the frame. The default variant is circle. The variant is set once on the root and applies to every item through context — there is no per-item variant, which keeps a group visually consistent.