Skip to content
Drivn
6 min read

Next.js Radio Group with the App Router

Add a radio group to a Next.js App Router app. Drivn ships a copy-paste client component with controlled and uncontrolled modes — install and render in minutes.

A radio group is the control for picking exactly one option from a short, visible set — a notification channel, a pricing tier, a shipping speed. In a Next.js App Router project the practical question is where the selection state lives, and Drivn's Radio Group answers it directly: the component owns its value through React.useState and shares it with each item through context, so radio-group.tsx opens with a 'use client' directive and renders inside a client boundary.

After install the component sits in src/components/ui/radio-group.tsx as a root function joined to its .Item sub-component with Object.assign. The root holds the selected value, supports both controlled (value plus onValueChange) and uncontrolled (defaultValue) modes, and passes 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 keyboard focus and form semantics come from the real input while the styling stays yours.

This guide installs Drivn in a Next.js 16 project, renders the group inside a client component, reads the choice with controlled state, and restyles the indicator. Every snippet comes from the component's real API. For the full reference see the Radio Group docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Radio Group.

Install in a Next.js 16 project

Drivn installs through a small CLI that writes component source directly into your repository — there is no runtime package to version-lock. From the root of your Next.js 16 project run npx drivn add radio-group. The Radio Group imports nothing past React and the local cn utility, so the CLI writes a single radio-group.tsx file and leaves the rest of your tree untouched. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own the file, and future Drivn releases will not overwrite it — commit it for a clean baseline before wiring it into a form. If the same screen collects other fields, pair it with the Drivn Input and Label from the same install.

1# from the root of your Next.js 16 project
2npx drivn add radio-group

Render inside a client component

Because the group holds its selected value in state, radio-group.tsx carries a 'use client' directive and renders inside a client boundary. The quickest shape is uncontrolled: pass defaultValue to the root and a value plus label to each RadioGroup.Item, and the component tracks the choice internally. One import covers the root and the item because they are joined with Object.assign. In a Next.js App Router page, keep the surrounding page a Server Component and mount the group inside a small client component — the 'use client' boundary belongs to whoever owns interactive state, not the whole route. Reach for controlled mode only when the parent needs to read or set the value as the user clicks. Each item accepts an optional description for a card-style option with a muted second line.

1'use client'
2import { RadioGroup } from '@/components/ui/radio-group'
3
4export 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}

Read the choice with controlled state

When the parent needs the current selection — to gate a submit button, to branch the UI, or to 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 detects the controlled prop (it checks controlledValue !== undefined) and defers to your state instead of its internal copy, so the parent becomes the single source of truth for the choice. From there you can send the value to a Server Action, disable a Button until a tier is chosen, or render a different panel per selection. The defaultValue and value props are mutually exclusive — pass defaultValue for a set-and-forget group, value plus onValueChange when the parent must read every change.

1'use client'
2import * as React from 'react'
3import { RadioGroup } from '@/components/ui/radio-group'
4
5export 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}

How the selection state works

The root owns one piece of state — the selected value — and shares it through context. It initializes React.useState(defaultValue ?? ''), computes isControlled from whether a value prop was passed, and derives current as the controlled value when present or the internal state otherwise. The onSelect callback sets internal state only in uncontrolled mode and always fires onValueChange, so both modes flow through the same handler. That value, the onSelect function, the disabled flag, and the variant go into the context provider, and each Item reads them with a useRadioGroup() hook. An item is checked when ctx.value === value, and clicking its hidden <input type="radio"> calls ctx.onSelect(value). One boolean of controlled-vs-uncontrolled logic, one context read per item — the whole state machine reads top to bottom in the file you own.

1// value ownership — verbatim from radio-group.tsx
2const [internal, setInternal] = React.useState(
3 defaultValue ?? ''
4)
5const isControlled = controlledValue !== undefined
6const current = isControlled ? controlledValue : internal
7
8const onSelect = (v: string) => {
9 if (!isControlled) setInternal(v)
10 onValueChange?.(v)
11}

Restyle the indicator and switch layout

Every class the group renders 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 Tailwind 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 the disabled prop on the root, or one option with disabled on a single Item. For copy-paste layouts and stateful patterns see the Radio Group examples.

1// indicator shape — verbatim from radio-group.tsx
2variants: {
3 circle: 'rounded-full',
4 square: 'rounded-[4px]',
5},
6indicators: {
7 circle: 'rounded-full',
8 square: 'rounded-[2px]',
9},
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

No. The root owns the selected value in React.useState and shares it through React.createContext, both of which are client-only APIs, so radio-group.tsx opens 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 page remains a Server Component. Only the interactive group ships to the client, not the whole route.

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 variant="square" to the RadioGroup root. The default circle applies rounded-full to the indicator and its inner dot; square applies rounded-[4px] to the indicator and rounded-[2px] to the dot through the variants and indicators maps in the styles object. It is a one-word change at the call site — no separate component and no extra config — because the variant flows through context to every item in the group.

Pass orientation="horizontal" to the RadioGroup root. The default vertical renders the base flex flex-col gap-3; horizontal adds styles.horizontal (flex-row gap-4) so the items sit side by side. The orientation is a prop on the root, so switching between a stacked list and an inline row is a one-word change with no layout math. Pick horizontal for two or three short labels and vertical for longer option lists with descriptions.

Yes. The component has no dependency on Next.js or any router — it is a self-contained client component built on React.useState and context, so it works anywhere React and Tailwind reach the DOM. In a Vite + React app everything already runs on the client, so the 'use client' directive is simply ignored. Run npx drivn add radio-group and render it the same way, driving the value from defaultValue or your own useState.