Radio group
One choice out of a few, all shown at once. One Tab stop for the group, and the checked radio is the tab stop before hydration.
client - size not measured yet
Radio group
client - size not measured yetOne choice out of a few, all shown at once. One Tab stop for the group, and the checked radio is the tab stop before hydration.
Try it
size
orientation
invalid
The code
radio-group.tsx
import { RadioGroupItem, RadioGroupRoot } from 'topclassui/radio-group'
<div className="grid justify-items-start gap-2">
<span id="radio-group-playground-label" className="text-sm text-body">Billing</span>
<RadioGroupRoot aria-labelledby="radio-group-playground-label" defaultValue="yearly">
<RadioGroupItem value="monthly">Monthly</RadioGroupItem>
<RadioGroupItem value="yearly">Yearly</RadioGroupItem>
<RadioGroupItem value="lifetime">Lifetime</RadioGroupItem>
</RadioGroupRoot>
</div>The group carries the state and the form; each RadioGroupItem carries only its value and its text.
Every state
4 statesImport from the package
topclassui/radio-group
import { RadioGroupRoot, RadioGroupItem } from 'topclassui/radio-group'Install once:npm i topclassui@beta zod
Examples
- For one choice out of a few. For many options, a Select reads better; for two to five short ones in a bar, a SegmentedControl.
- In a form the group sends the checked value, and nothing while no radio is checked. Before the page hydrates it sends the default.
orientationisvertical(a column) orhorizontal(a row that wraps). It is layout only: the arrow keys are the same in both.- A long text wraps under itself rather than ending in an ellipsis: a choice is read whole.
- On an item,
stylegoes to the<label>;refgoes to the radio button itself.
basic
Billing
basic.tsx
import { RadioGroupItem, RadioGroupRoot } from 'topclassui/radio-group'
/** Outside a Field: the group is named by the visible text above it, and each radio by its own text. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-2">
<span id="radio-group-basic-label" className="text-sm text-body">Billing</span>
<RadioGroupRoot aria-labelledby="radio-group-basic-label" defaultValue="yearly">
<RadioGroupItem value="monthly">Monthly</RadioGroupItem>
<RadioGroupItem value="yearly">Yearly</RadioGroupItem>
<RadioGroupItem value="lifetime">Lifetime</RadioGroupItem>
</RadioGroupRoot>
</div>
)
}
horizontal
horizontal.tsx
import { RadioGroupItem, RadioGroupRoot } from 'topclassui/radio-group'
/** orientation="horizontal" lays the radios in a row that wraps on a narrow screen. The arrow keys are the same. */
export default function Demo() {
return (
<RadioGroupRoot aria-label="Size" orientation="horizontal" defaultValue="m">
<RadioGroupItem value="s">Small</RadioGroupItem>
<RadioGroupItem value="m">Medium</RadioGroupItem>
<RadioGroupItem value="l">Large</RadioGroupItem>
<RadioGroupItem value="xl">Extra large</RadioGroupItem>
</RadioGroupRoot>
)
}
form
form.tsx
import { Button } from 'topclassui/button'
import { RadioGroupItem, RadioGroupRoot } from 'topclassui/radio-group'
/** The group carries the name and the default; each radio only its value and its text. It sends plan=pro, before any
* script runs and after. */
export default function Demo() {
return (
<form className="grid justify-items-start gap-3">
<span id="radio-group-form-label" className="text-sm text-body">Plan</span>
<RadioGroupRoot aria-labelledby="radio-group-form-label" name="plan" defaultValue="pro">
<RadioGroupItem value="free">Free</RadioGroupItem>
<RadioGroupItem value="pro">Pro</RadioGroupItem>
<RadioGroupItem value="team">Team</RadioGroupItem>
</RadioGroupRoot>
<Button type="submit">Save</Button>
</form>
)
}
sizes
sizes.tsx
import { RadioGroupItem, RadioGroupRoot } from 'topclassui/radio-group'
/** The five sizes, one group each, the first radio checked. Each target stays 24 px, and 44 px under a finger. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-4">
<RadioGroupRoot size="xs" aria-label="Extra small" defaultValue="on" orientation="horizontal">
<RadioGroupItem value="on">Extra small</RadioGroupItem>
<RadioGroupItem value="off">Off</RadioGroupItem>
</RadioGroupRoot>
<RadioGroupRoot size="sm" aria-label="Small" defaultValue="on" orientation="horizontal">
<RadioGroupItem value="on">Small</RadioGroupItem>
<RadioGroupItem value="off">Off</RadioGroupItem>
</RadioGroupRoot>
<RadioGroupRoot size="md" aria-label="Medium" defaultValue="on" orientation="horizontal">
<RadioGroupItem value="on">Medium</RadioGroupItem>
<RadioGroupItem value="off">Off</RadioGroupItem>
</RadioGroupRoot>
<RadioGroupRoot size="lg" aria-label="Large" defaultValue="on" orientation="horizontal">
<RadioGroupItem value="on">Large</RadioGroupItem>
<RadioGroupItem value="off">Off</RadioGroupItem>
</RadioGroupRoot>
<RadioGroupRoot size="xl" aria-label="Extra large" defaultValue="on" orientation="horizontal">
<RadioGroupItem value="on">Extra large</RadioGroupItem>
<RadioGroupItem value="off">Off</RadioGroupItem>
</RadioGroupRoot>
</div>
)
}
long-content
long-content.tsx
import { RadioGroupItem, RadioGroupRoot } from 'topclassui/radio-group'
/** A choice is read whole: a long text wraps under itself, and a word longer than the line breaks anywhere. */
export default function Demo() {
return (
<RadioGroupRoot aria-label="Delivery" defaultValue="standard">
<RadioGroupItem value="standard">
Standard delivery, three to five working days, left with a neighbour or at the nearest pick-up point if nobody is home
</RadioGroupItem>
<RadioGroupItem value="link">
https://example.com/delivery/options/express-next-day-before-noon-with-signature-and-tracking
</RadioGroupItem>
<RadioGroupItem value="short">Pick up</RadioGroupItem>
</RadioGroupRoot>
)
}
In Arabic, right to left
الفوترة
rtl.tsx
import { TopClassProvider } from 'topclassui/provider'
import { RadioGroupItem, RadioGroupRoot } from 'topclassui/radio-group'
/** Arabic, inside TopClassProvider as the root layout would mount it. Each circle sits at the right, before its text,
* and ArrowRight goes to the radio before. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="grid justify-items-start gap-2">
<span id="radio-group-rtl-label" className="text-sm text-body">الفوترة</span>
<RadioGroupRoot aria-labelledby="radio-group-rtl-label" name="billing" defaultValue="yearly">
<RadioGroupItem value="monthly">شهريًا</RadioGroupItem>
<RadioGroupItem value="yearly">سنويًا</RadioGroupItem>
<RadioGroupItem value="lifetime">مدى الحياة</RadioGroupItem>
</RadioGroupRoot>
</div>
</TopClassProvider>
)
}
Props
RadioGroupRoot
RadioGroupItem
Keyboard and accessibility
- Name the group with
aria-labeloraria-labelledby: no<label htmlFor>can name a group. Each radio is named by its own text. - The target is 24px, 44px under a touch pointer, whatever
sizedraws: the focus ring is around the circle. invalidsets aria-invalid on the group: every circle turns to danger and its line thickens, checked or not.
Right to left
Each circle sits at the right, before its text. Under a right-to-left TopClassProvider, ArrowRight goes to the radio before.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.