Building a Radio Group Component with Keyboard Navigation

A group of radio inputs is not just a list of checkboxes that only allow one selection. It's a distinct ARIA pattern with its own keyboard contract — arrow keys move between options, Tab moves out of the group entirely. Get this wrong and keyboard users are stuck tabbing through every option one by one. Get it right and the group behaves exactly as assistive technology and keyboard users expect.
Most radio implementations are a loop of <input type="radio"> elements with a shared name attribute. The browser handles mutual exclusivity, form submission works, and it looks fine. But the keyboard behaviour is wrong.
The ARIA specification for a radio group says arrow keys should move between options — not the Tab key. A user who tabs into a radio group should land on the selected option (or the first option if none is selected), then use arrow keys to move within the group, then Tab to leave. Tab-navigating through every radio option is incorrect behaviour, and native radio inputs don't implement the arrow key pattern on their own.
This is the component where we introduce the compound component pattern properly — RadioGroup as the container, RadioItem as each option — and handle keyboard navigation ourselves.
The compound component pattern
The RadioGroup is our first proper compound component. Instead of a single component that accepts an array of options as a prop, we expose two components that consumers compose:
This API is more flexible than an options array — consumers can render anything between RadioItems, wrap items in layout components, or add arbitrary content alongside each option. The container manages state and keyboard behaviour; the items render themselves.
The four files
RadioGroup.variants.ts
The radio dot is drawn with CSS — add this to tokens.css under @layer base alongside the checkbox background image:
RadioGroup.types.ts
RadioGroupProps exposes value and onValueChange rather than the native checked and onChange — this gives a cleaner API that treats the group as a single controlled unit rather than a collection of individual inputs. defaultValue supports uncontrolled mode.
RadioItemProps requires value — every item must have a value to be selectable. label is optional for the same reason as Checkbox — consumers may want to render their own label content.
RadioGroup.tsx
What each decision is doing
Context for shared state — RadioGroup owns the selected value and passes it down to every RadioItem via context. Each RadioItem compares ctx.value === value to determine if it's checked. This is cleaner than prop drilling through an arbitrary number of items and more flexible than passing an options array.
useRadioGroup guard — the hook throws if RadioItem is used outside a RadioGroup. This is a development-time guard that gives consumers a clear error message rather than a silent rendering bug when the component is misused.
Controlled and uncontrolled modes — RadioGroup handles both. If value is passed, the component is controlled — internalValue is ignored and state lives with the consumer. If only defaultValue is passed, the component manages its own state internally. The isControlled flag determines which path is active.
Arrow key navigation via querySelectorAll — on arrow key press, we query all enabled radio inputs inside the group, find the currently focused one, and move focus to the next or previous input in the list — wrapping around at the ends. We also call .click() on the newly focused input to select it, which triggers the onChange and updates the value. This matches the ARIA radio group keyboard contract exactly.
tabIndex is handled by the browser — native radio inputs in a group handle roving tabIndex automatically. When one radio is selected, only that radio is in the tab sequence. When none are selected, only the first is. We don't need to implement this ourselves — it's another benefit of keeping the native input.
Group-level disabled — disabled on RadioGroup propagates to every RadioItem via context. isDisabled = disabled || ctx.disabled means item-level disabled also works independently.
Shared name — all items in the group share the same name attribute, which is how the browser enforces mutual exclusivity and groups them for form submission. We generate a stable unique name with useId if the consumer doesn't provide one.
The component index
src/components/RadioGroup/index.ts:
Update the root entry point
Add to src/index.ts:
Usage
The decision rule
Use RadioGroup when the user must select exactly one option from a visible list and all options are known upfront. Use Select when there are more than five or six options, when the options are dynamic, or when screen space is limited. A radio group showing ten options is a UX problem — that's what a select is for.
Up next: Building a Select component — when to use native and when to go custom — the logical follow-up to RadioGroup. When a list of options gets too long for radio buttons, Select takes over — and the native vs custom decision gets more interesting.
Related: Building a Checkbox component — custom styles without losing native behaviour — Checkbox and RadioGroup share the native-input approach and the CSS background image technique for the checked state. Worth reading together.
Muhammad Athar is the founder of DesignDev.io and the engineer behind everything you read here. He writes about the decisions behind building real products — the components, the architecture, and the tradeoffs that don't make it into the tutorial.
Photo by Amélie Mourichon on Unsplash

