Base components
Radio group
A group of options where only one can be chosen, as plain radios or as selectable cards, where the chosen dot grows in from a small scale and the card border turns brand.
Live preview
Loading preview
A plan picker with three Icon card items (Basic plan, Business plan with the badge "Limited time only", Enterprise plan), a plain radio list for "Email me about" with three options, and a Payment icon list with two cards and Set as default and Edit actions. Replay chooses each plan card in turn.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add radio-groupUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Badge } from '../badge/badge';
import { Button } from '../button/button';
import { RadioGroup, RadioGroupItem } from './radio-group';
const STEP_MS = 500;
const PLANS = [
{
value: 'basic',
label: 'Basic plan',
price: '$10',
text: 'Up to 10 people and 20 GB of storage.',
icon: 'M8 2 2 5l6 3 6-3-6-3ZM2 8l6 3 6-3M2 11l6 3 6-3',
},
{
value: 'business',
label: 'Business plan',
price: '$20',
text: 'Up to 20 people and 40 GB of storage.',
icon: 'M9 1.5 3 9h5l-1 5.5L13 7H8l1-5.5Z',
},
{
value: 'enterprise',
label: 'Enterprise plan',
price: '$40',
text: 'No limit on people or storage.',
icon: 'M2.5 14V3.5l5-2v12.5M7.5 6h6v8M1.5 14h13M4.5 5.5v0M4.5 8.5v0M10 9v0M10 11.5v0',
},
];
const CARDS = [
{ value: 'card-1234', label: 'Card ending in 1234', expiry: 'Expires 06/2028' },
{ value: 'card-5678', label: 'Card ending in 5678', expiry: 'Expires 11/2027' },
];
function FeaturedIcon({ path }: { path: string }) {
return (
<span className="flex size-8 items-center justify-center rounded-md border border-primary bg-primary text-fg-secondary shadow-xs-skeuomorphic">
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.33"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
>
<path d={path} />
</svg>
</span>
);
}
function CardIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 34 24" fill="none">
<rect
x="0.5"
y="0.5"
width="33"
height="23"
rx="3.5"
className="fill-(--lunia-bg-primary) stroke-(--lunia-border-secondary)"
/>
<rect x="5" y="6" width="7" height="5" rx="1" className="fill-fg-quaternary" />
<path d="M5 16h8M16 16h5M24 16h5" strokeLinecap="round" className="stroke-fg-quaternary" />
</svg>
);
}
export default function RadioGroupDemo({ replay = 0 }: { replay?: number }) {
const [plan, setPlan] = React.useState('business');
const [topic, setTopic] = React.useState('news');
const [card, setCard] = React.useState('card-1234');
React.useEffect(() => {
if (replay === 0) return;
// Each plan card is chosen in turn, then the first one again.
const order = ['basic', 'business', 'enterprise', 'basic'];
setPlan('enterprise');
const timers = order.map((value, i) => window.setTimeout(() => setPlan(value), (i + 1) * STEP_MS));
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [replay]);
return (
<div className="grid w-full gap-x-8 gap-y-8 sm:grid-cols-2">
<div className="flex flex-col gap-3">
<span id="radio-demo-plan" className="text-sm font-medium text-secondary">
Plan
</span>
<RadioGroup type="icon-card" aria-labelledby="radio-demo-plan" value={plan} onValueChange={setPlan}>
{PLANS.map((p) => (
<RadioGroupItem
key={p.value}
value={p.value}
label={p.label}
supportingText={p.text}
media={<FeaturedIcon path={p.icon} />}
badge={
p.value === 'business' ? (
<Badge type="badge-modern" color="success" size="sm" dot>
Limited time only
</Badge>
) : undefined
}
>
<span className="flex items-baseline gap-1">
<span className="text-display-sm font-semibold text-primary">{p.price}</span>
<span className="text-md text-tertiary">per month</span>
</span>
</RadioGroupItem>
))}
</RadioGroup>
</div>
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-3">
<span id="radio-demo-topic" className="text-sm font-medium text-secondary">
Email me about
</span>
<RadioGroup aria-labelledby="radio-demo-topic" value={topic} onValueChange={setTopic}>
<RadioGroupItem value="news" label="News and updates" />
<RadioGroupItem value="tips" label="Tips and guides" />
<RadioGroupItem value="none" label="Only account emails" />
</RadioGroup>
</div>
<div className="flex flex-col gap-3">
<span id="radio-demo-card" className="text-sm font-medium text-secondary">
Payment method
</span>
<RadioGroup
type="payment-icon"
aria-labelledby="radio-demo-card"
value={card}
onValueChange={setCard}
>
{CARDS.map((c) => (
<RadioGroupItem
key={c.value}
value={c.value}
label={c.label}
supportingText={c.expiry}
media={<CardIcon />}
actions={
<>
<Button hierarchy="link-gray" size="sm">
Set as default
</Button>
<Button hierarchy="link-color" size="sm">
Edit
</Button>
</>
}
/>
))}
</RadioGroup>
</div>
</div>
</div>
);
}
API
Parts: root (radiogroup), item: plain radio row (control, label, supporting text), item: card (border, control, featured icon or avatar or payment icon, title, supporting text, badge, actions), control (circle), dot.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | undefined | Controlled chosen value. |
| defaultValue | string | undefined | Chosen value when uncontrolled. |
| type | "plain" | "radio-button" | "icon-simple" | "icon-card" | "avatar" | "payment-icon" | "plain" | plain is a radio with a label (the Figma Checkbox set, Type Radio); the others are the Figma card types. |
| size | "sm" | "md" | "sm" | Control of 16 or 20px and the matching text and card padding. |
| name | string | undefined | Form field name. |
| required | boolean | false | A choice is needed before the form is sent. |
| disabled | boolean | false | Turns off every item. Items also take their own disabled prop. |
| orientation | "vertical" | "horizontal" | "vertical" | Direction of the stack; arrow keys work in both directions either way. |
| Event | Description |
|---|---|
| valueChange | A new item was chosen. Payload: its value. |
| Slot | Description |
|---|---|
| default | RadioGroupItem children. Each item takes value, label, supportingText, disabled and the slots below. |
| media | RadioGroupItem: featured icon, avatar or payment icon of a card. |
| badge | RadioGroupItem: badge on an Icon card, for example "Limited time only". |
| actions | RadioGroupItem: link buttons under the text of a Payment icon card, for example Set as default and Edit. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| RG1 | hover an unchosen item | control | background-color, border-color | bg-primary, border-control → bg-primary_hover, border-brand | duration-sm (200)ease-out | Swaps at once |
| RG2 | an item is chosen | control | background-color, border-color | bg-primary, border-control → bg-brand-solid, bg-brand-solid | duration-sm (200)ease-out | Swaps at once |
| RG3 | an item is chosen | dot | opacity, scale | 0, scale-swap → 1, 1 | duration-md (260)ease-outdelay 60ms | Shows at once |
| RG4 | the chosen item changes | dot and control of the item that was chosen | opacity, scale (dot); background-color, border-color (control) | 1, 1; bg-brand-solid → 0, scale-swap; bg-primary, border-control | duration-xs (160)ease-exit | Hides at once |
| RG5 | a card is chosen or left | card border | box-shadow (inset 2px border-brand over the 1px border-primary) | none → inset 0 0 0 2px border-brand | duration-sm (200)ease-out | Swaps at once |
| RG6 | press (pointer down) on a plain radio | control | scale | 1 → scale-press | duration-sm (200)ease-out | Stays still |
| RG7 | keyboard focus | control (cards: the card and its control) | box-shadow | none → focus ring (2px bg-primary gap, 4px focus-ring) | duration-2xs (120)ease-out | Shows at once |
Accessibility
- Built on a radio group primitive: role radiogroup with role radio items and aria-checked; one Tab stop, and the arrow keys move and choose.
- Name the group with a visible legend or heading linked by aria-labelledby.
- Card items are the radio themselves: the title is the name and the supporting text is linked with aria-describedby; buttons inside a card (actions) stay separate Tab stops after the group.
- Plain radios without label text are 16 or 20px: give them a 24 x 24px tap area or 4px of space around them (WCAG 2.5.8). With text or as cards, the whole row is the tap area.
- The card border change is not the only signal: the control shows the dot.
- Reduced motion: the dot and the borders change at once and nothing scales.