How to Add a Radio Group to a React App
Step-by-step guide to adding a radio group to any React project with the Drivn CLI — a copy-paste client component with native inputs and zero dependencies.
A radio group answers one question — which single option? — for a notification channel, a pricing tier, or a shipping speed. Building one by hand means a little state (track the selected value and clear the rest), a little markup (a real <input type="radio"> per option so forms and keyboards work), and a little layout (a label, an optional description, an indicator that fills when checked). None of it is hard, but it is repetitive enough that most teams reach for a component.
Drivn takes the copy-paste route: the CLI writes a radio-group.tsx file straight into your repository. The Radio Group is a root function joined to its .Item sub-component with Object.assign. The root owns the selected value through React.useState, supports controlled (value plus onValueChange) and uncontrolled (defaultValue) modes, and shares an onSelect callback down through a React.createContext. Each Item renders a visually hidden native <input type="radio"> as a peer, then draws the circle or square indicator from the styles object beside its label — so focus and form semantics come from the real input while the styling stays yours.
This guide adds the group to any React app — install the CLI, render an uncontrolled group, lift the value into controlled state, and restyle the indicator. It works the same in Vite + React or a Next.js project. For the App Router walkthrough see the Next.js Radio Group guide.
Prerequisites
Before installing Radio Group, confirm your React project has the three things Drivn assumes: Tailwind CSS v4 installed and processing your styles, TypeScript configured (the component ships as a .tsx file), and a @/ path alias pointing at your source directory. If you scaffolded with create-next-app, npm create vite, or npx drivn@latest create, all three are already wired. For a custom setup, check the compilerOptions.paths entry in tsconfig.json; the installation page lists the minimal config. Radio Group imports nothing past React and the cn utility for class merging, so it is a single-file install — no peer components and no runtime package to version-lock. Because it builds on a native <input type="radio"> rather than a headless primitive, it also drops cleanly into an existing form without pulling in a keyboard-navigation or focus-management library.
Step 1 — Install Drivn via the CLI
Run the CLI from your project root to add the Radio Group source file. The command prompts once for your install directory (defaulting to src/components/ui/) and writes a single radio-group.tsx — no other files, because the group has no peer dependencies beyond React and the cn utility. No global config file is created; the result is TypeScript source you edit like any other component. Confirm it landed in your editor, then commit it for a clean baseline. If your project uses a monorepo layout or a non-standard source path, the CLI docs cover the flags for targeting a custom location during install. Because the file is yours after install, future Drivn releases will not overwrite your edits.
1 # add the Radio Group source file 2 npx drivn add radio-group
Step 2 — Render an uncontrolled group
Import RadioGroup from your UI directory — one import covers the root and the .Item because they are joined with Object.assign. The quickest shape is uncontrolled: pass defaultValue to the root to pre-select an option, then give each RadioGroup.Item a unique value and a label. The component tracks the choice in its own React.useState, so the surrounding code stays declarative. Because the group holds state, radio-group.tsx carries a 'use client' directive; in a Next.js App Router page keep the route a Server Component and mount the group inside a small client component. Each item wraps a real hidden <input type="radio">, so clicking the label toggles the radio, the browser groups same-name inputs, and the value posts in a plain HTML form — no extra wiring. Add a description prop for a two-line, card-style option.
1 'use client' 2 import { RadioGroup } from '@/components/ui/radio-group' 3 4 export function NotificationChannel() { 5 return ( 6 <RadioGroup defaultValue="email"> 7 <RadioGroup.Item value="email" label="Email" /> 8 <RadioGroup.Item value="sms" label="SMS" /> 9 <RadioGroup.Item value="push" label="Push" /> 10 </RadioGroup> 11 ) 12 }
Step 3 — Lift the value into controlled state
When the parent needs the current selection — to gate a submit button, branch the UI, or post the value to a route handler — switch to controlled mode. Hold the value in React.useState, pass it as the value prop, and update it from onValueChange. The root checks controlledValue !== undefined and defers to your state instead of its internal copy, so the parent becomes the single source of truth. The defaultValue and value props are mutually exclusive: pass defaultValue for a set-and-forget group, value plus onValueChange when you must read every change. onValueChange receives the new value string directly, with no event.target unwrapping — drop it into a Button disabled check or a conditional render. For form validation, the same controlled contract wires straight into react-hook-form with a Controller.
1 'use client' 2 import * as React from 'react' 3 import { RadioGroup } from '@/components/ui/radio-group' 4 5 export function PlanPicker() { 6 const [plan, setPlan] = React.useState('startup') 7 return ( 8 <RadioGroup value={plan} onValueChange={setPlan}> 9 <RadioGroup.Item value="startup" label="Startup" /> 10 <RadioGroup.Item value="business" label="Business" /> 11 </RadioGroup> 12 ) 13 }
Step 4 — Restyle the indicator and switch layout
Everything visual lives in one styles object at the top of the file you own. The indicator shape is a variant prop on the root — circle (the default) applies rounded-full, square applies rounded-[4px] — and the inner dot mirrors the shape through the indicators map. Switch the group to a row by passing orientation="horizontal", which adds styles.horizontal (flex-row gap-4) to the flex flex-col gap-3 base. The checked indicator reads bg-foreground and the labels read tokens like text-foreground and text-muted-foreground, so changing those tokens in theming re-themes every radio group for dark and light mode at once. Disable the whole set with disabled on the root, or a single option with disabled on one Item. For copy-paste layouts and stateful patterns see the Radio Group examples.
1 // verbatim from radio-group.tsx 2 variants: { 3 circle: 'rounded-full', 4 square: 'rounded-[4px]', 5 }, 6 indicators: { 7 circle: 'rounded-full', 8 square: 'rounded-[2px]', 9 },
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
Frequently asked questions
Yes. Set the project up with TypeScript and Tailwind v4 first, then run npx drivn add radio-group. The component has no dependency on Next.js or any router — it is a self-contained group built on React.useState, context, and a native <input type="radio">, with no imports past React and the cn utility. In a Vite + React app everything already runs on the client, so the 'use client' directive is simply ignored; render the group and drive its value from defaultValue or your own useState exactly as you would in Next.js.
Yes. The root owns the selected value in React.useState and shares it through React.createContext, both client-only APIs, so radio-group.tsx ships with a 'use client' directive. In a Next.js App Router page the boundary stays local — mount the group inside a small client component and the surrounding route remains a Server Component. Only the interactive group ships to the client, not the whole page.
Use uncontrolled mode — pass defaultValue and let the component track the choice — when nothing outside the group needs to read the selection. Switch to controlled mode — pass value plus onValueChange and hold the value in your own React.useState — when the parent must read the choice to gate a submit, branch the UI, or post it to a route handler. The root detects controlledValue !== undefined and defers to your state, so the two props are mutually exclusive.
Pass the label and description props directly on RadioGroup.Item. The component renders the label as a text-sm font-medium line and the description as a text-sm text-muted-foreground line stacked under it, and the whole two-line block sits inside the item's <label> element so a tap anywhere selects the option. When the built-in layout is not enough — say a price badge aligned to the right — pass children instead of label and render any JSX you want beside the radio.
Yes. Each RadioGroup.Item renders a real hidden <input type="radio">, and any extra props — including name — spread onto that input through the forwarded attributes. Give the items a shared name and the browser groups them; on submit the selected value appears in FormData with no hidden mirror field. This is the practical payoff of Drivn building the group on a native input rather than a headless button primitive.

