Radio Group React component for Tailwind CSS v4
v1.0.1Updated in 1.9.0
A React radio group to pick one of a few visible options: rows with a dot that springs in, or cards with icons, prices and a sliding ring.
Click or tap anywhere on a row or card to choose it: the circle fills with the tone and the dot springs in, and on cards a ring slides from the old choice to the new one while the icon tile takes the tone. The circle dips under your finger while you press, and a card dips slightly as a whole. Tab lands on the chosen option (or the first one when nothing is chosen), the arrow keys move and choose at once (Up and Left go back, Down and Right go forward, mirrored in right-to-left layouts, wrapping at the ends unless loop is off), Space chooses the focused option, and Tab leaves the group. Clicking the group's label focuses the chosen option. On a phone every row grows to 44px so each one is a full finger target, grids drop columns once a cell would get too narrow (a phone always gets one), cards stack their icon above the text when they are narrow, and horizontal rows wrap instead of scrolling. A read-only group keeps its choice visible and focusable but ignores changes; a disabled option dims and is skipped by the arrow keys. With reduced motion the dot and the ring jump instead of springing.
Radio Group is a free React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add radio-group, together with Field, useControllableState, Motion presets, Tones and UIProvider and the npm packages @radix-ui/react-radio-group and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add radio-groupUsage
Import RadioGroup from @/components/ui/radio-group.
import { Package, Store, Truck } from "lucide-react"
import { RadioGroup, type RadioOption } from "@/components/ui/radio-group"
const delivery: RadioOption[] = [
{ value: "courier", label: "Home delivery", description: "FedEx, 1-2 days", icon: <Truck />, badge: "$9.99" },
{ value: "locker", label: "Locker", description: "Pick up any time from 3,000 lockers", icon: <Package />, badge: "$5.99" },
{ value: "pickup", label: "In-store pickup", description: "Austin, 2400 S Congress Ave", icon: <Store />, badge: "Free" },
]
export function DeliveryMethod({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return (
<RadioGroup
name="delivery"
label="Delivery method"
variant="card"
options={delivery}
value={value}
onValueChange={onChange}
required
/>
)
}
// custom content: compose the parts
import { RadioGroupRoot, RadioGroupItem } from "@/components/ui/radio-group"
export function PlanPicker() {
return (
<RadioGroupRoot label="Plan" variant="card" columns={2} defaultValue="pro">
<RadioGroupItem value="free" label="Free" badge="$0" />
<RadioGroupItem value="pro" label="Pro" badge="$9/mo">
<ul className="mt-2 text-[13px] text-muted-foreground">
<li>Unlimited listings</li>
</ul>
</RadioGroupItem>
</RadioGroupRoot>
)
}Props
optionsdefault"[]"RadioOption[]The choices: { value, label, description?, icon?, badge?, disabled? }. badge is a trailing price or tag on the label's line. For layouts an array cannot express, compose RadioGroupRoot with RadioGroupItem children (see Parts).
valuestringThe chosen value when controlled. Pair it with onValueChange.
onValueChange(value: string) => voidCalled with the new value when the person picks an option, by pointer, Space or the arrow keys. Not called while readOnly.
labeldefault""ReactNodeThe group's label, above the options. It names the radiogroup through aria-labelledby, and clicking it focuses the chosen option. Leave it empty and pass aria-label for a bare group.
descriptiondefault""ReactNodeHelper text under the options, linked with aria-describedby. The error takes its place while it shows.
errordefault""ReactNodeReplaces the description with an alert icon and the text, slides in and marks the group invalid: empty circles and card borders turn red.
requireddefaultfalsebooleanThe red mark after the label, aria-required on the group, and required on the hidden form inputs, so a native form will not submit without a choice.
invaliddefaultfalsebooleanThe invalid look and aria-invalid without a message, for forms that show errors elsewhere.
disableddefaultfalsebooleanDims every option and blocks it; a form will not submit its value. Disable one option through its own disabled.
readOnlydefaultfalsebooleanThe choice stays visible and focusable, but clicks and arrow keys do not change it (aria-readonly). No hover tint, no press dip.
variantdefault"default""default" | "card"default: compact rows with the circle beside the text. card: soft-filled tiles with an icon tile, a description and a trailing price, and one selection ring that slides between cards.
sizedefault"md""sm" | "md" | "lg"Circle 16 / 18 / 20px with a 6 / 7 / 8px dot, label 13 / 14 / 15px, card padding 12 / 16 / 20px.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The checked fill, the dot's contrast color and the card ring. neutral is black in light mode and white in dark mode; brand orange is opt-in.
colordefault""stringAny CSS color. Replaces the tone; the dot turns black or white by lightness. Leave empty to use the tone.
indicatordefault"dot""dot" | "check"What appears in the filled circle: a dot that springs in, or a check mark that draws itself (nice on cards).
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Cards only: corners of 0 / 12 / 16 / 24px (full falls back to 24px). The ring follows it.
orientationdefault"vertical""vertical" | "horizontal"horizontal puts rows side by side and wraps them onto the next line instead of scrolling; cards share the row until they would get narrower than 10rem. Every arrow key works in both, like native radios.
columnsdefault11 | 2 | 3 | 4Up to this many columns of rows or cards. The grid follows the space it has, not the viewport: it drops a column whenever cells would get narrower than 12rem (cards) or 11rem (rows), so a phone always gets one column and a narrow sidebar never squeezes the text.
controlPositiondefault"auto""auto" | "start" | "end"Where the circle sits: auto is before the text for rows and at the top end of cards. end suits settings lists; start suits cards that read like a checklist.
showControldefaulttruebooleanCards only: false hides the circle and lets the ring and the tinted icon tile show the choice (plan pickers). The radio stays in the accessibility tree.
defaultValuedefault""stringThe option chosen on first render while uncontrolled. "" chooses nothing, and the first enabled option is then the tab stop. A value that matches no option also shows nothing chosen. The demo starts on curier while this is empty; try easybox or ridicare.
loopdefaulttruebooleanThe arrow keys wrap from the last option to the first and back. The demo's key hint says which.
namedefault""stringForm field name. Inside a <form> each option renders a hidden native radio with this name, so FormData and native validation work; a form reset returns to defaultValue. The demo prints what the form would submit.
idstringThe group's id (the radiogroup element). Defaults to useId(); the label and messages derive their ids from it.
fieldPropsPartial<FieldProps>Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel.
classNamesPartial<Record<"group" | "option" | "body" | "item" | "indicator" | "icon" | "content" | "label" | "description" | "badge" | "ring" | "field" | "fieldLabel" | "fieldDescription" | "error", string>>Extra classes for the parts. label and description style each option's text; fieldLabel and fieldDescription the group's own label and hint; group is the radiogroup element, option each row or card, body the inner row of a card (the part that stacks when the card is narrow), item the circle.
classNamestringClasses for the outermost element: the Field wrapper when there is a label, description or error, otherwise the radiogroup. Override the tone vars here, e.g. [--tone:#7C3AED].
refRefObject<HTMLDivElement | null> or (node) => voidThe radiogroup element. Every other prop (aria-label, dir, form, data-*) lands there too.
Parts
RadioGroupRoot
componentThe group without the options array, for layouts an array cannot express: plan cards with feature lists, mixed rows. Takes every RadioGroup prop except options, plus RadioGroupItem children.
RadioGroupItem
componentOne option inside RadioGroupRoot: a <label> row or card whose whole surface picks it. Children render under the description; other props and ref go on the label.
value*stringThe value the group reports when this option is picked.
labelReactNodeThe option's name.
descriptionReactNodeA muted line under the label.
iconReactNodeA leading icon: muted beside a row, in a tinted tile on a card.
badgeReactNodeTrailing text on the label's line, like a price or a tag.
disableddefaultfalsebooleanDims this option and skips it with the arrow keys.
classNamesPartial<Record<RadioItemSlot, string>>Extra classes for the option's inner parts.
childrenReactNodeExtra content under the description, such as plan features.
RadioOption
typeOne entry of options: { value, label, description?, icon?, badge?, disabled? }.
Examples
Payment method
Rows with icons, a description and a trailing surcharge. The whole row is the target.
Horizontal
Short choices side by side; they wrap onto the next line on a phone instead of scrolling.
Plan cards
RadioGroupRoot with custom items in 3 columns, showControl={false} and the brand tone: the ring alone marks the plan.
Sizes, tones and a custom color
16, 18 and 20px circles; any tone or CSS color, with a dot or a check that draws itself.
Disabled and read-only
A disabled option dims and the arrow keys skip it; a read-only group keeps its choice and stays focusable.
Required, checked on submit
Submit with nothing chosen: the error slides in and focus moves to the group. Picking a card clears it.
Settings list
controlPosition="end" and indicator="check" inside a list group, like a phone settings screen.
Delivery slots in two columns
Small cards in a 2-column grid that falls to one column on phones; a full slot is disabled.
Files
- components/ui/radio-group.tsx
FAQ
Is the React Radio Group component free?
Yes. The source is free and needs no account: copy it from the Code tab or install it with the CLI.
How do I install Radio Group?
Run npx wingo-ui@latest add radio-group. It also adds the Wingo UI items it builds on (Field, useControllableState, Motion presets, Tones and UIProvider) and the npm packages @radix-ui/react-radio-group and motion. With shadcn, run npx shadcn@latest add https://wingo-ui.com/r/radio-group.json.
How does Radio Group behave on phones?
On a phone every row grows to 44px so each one is a full finger target, grids drop columns once a cell would get too narrow (a phone always gets one), cards stack their icon above the text when they are narrow, and horizontal rows wrap instead of scrolling.
See also
Used in
More React input components
Articles using this component
- React Segmented Control: iOS Thumb, Drag and When to Use It
Mobile UI in React8 min read