Radio Group
A set of options of which one at most is chosen, navigated with the arrow keys.
- v1.0.0
- WAI-ARIA: Radio Group
- SSR
- Zoneless
- 3.1 kB gzip
- +1.6 kB Angular CDK, shared
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Label } from '@/ui/label/label';
import {
RadioGroup,
RadioGroupIndicator,
RadioGroupItem,
} from '@/ui/radio-group/radio-group';
/** Three options with labels, one chosen by default. */
@Component({
selector: 'example-radio-group-demo',
imports: [Label, NdIcon, RadioGroup, RadioGroupIndicator, RadioGroupItem],
template: `
<div ndRadioGroup aria-label="Density" defaultValue="comfortable">
@for (option of options; track option.value) {
<div class="flex items-center gap-3">
<button
ndRadioGroupItem
[value]="option.value"
[id]="'radio-group-demo-' + option.value"
>
<span ndRadioGroupIndicator><svg ndIcon="circle"></svg></span>
</button>
<label ndLabel [for]="'radio-group-demo-' + option.value">
{{ option.label }}
</label>
</div>
}
</div>
`,
})
export class RadioGroupDemoExample {
protected readonly options = [
{ value: 'default', label: 'Default' },
{ value: 'comfortable', label: 'Comfortable' },
{ value: 'compact', label: 'Compact' },
];
}
A radio group offers a few options of which one is chosen, such as a
plan or a delivery speed. It's announced as a radio group: Tab moves into
it, to the chosen option, and the arrow keys move between options and
choose them, mirrored in right-to-left layouts. Its value is the chosen
option's value, bound with [(value)] or any forms library.
Installation
npx @ndop/cli add radio-grouppnpm dlx @ndop/cli add radio-groupyarn dlx @ndop/cli add radio-groupbunx @ndop/cli add radio-groupInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/radio-group/radio-group.tsimport { computed, Directive, input } from '@angular/core'; import { NdRadioGroup, NdRadioGroupIndicator, NdRadioGroupItem, } from '@ndop/primitives/radio-group'; import { cn } from '@/lib/utils'; /** * A radio group (`@ndop/primitives/radio-group`): options of which one at * most is checked, with the arrow keys moving between them. Bind it with * `[(value)]` or any forms library, and name it with `aria-label` or * `aria-labelledby`. * * @example * ```html * <div ndRadioGroup aria-label="Plan" [(value)]="plan"> * <div class="flex items-center gap-3"> * <button ndRadioGroupItem value="free" id="free"> * <span ndRadioGroupIndicator><svg ndIcon="circle"></svg></span> * </button> * <label ndLabel for="free">Free</label> * </div> * </div> * ``` */ @Directive({ selector: '[ndRadioGroup]', hostDirectives: [ { directive: NdRadioGroup, inputs: [ 'value', 'defaultValue', 'orientation', 'disabled', 'required', 'invalid', ], outputs: ['valueChange'], }, ], host: { 'data-slot': 'radio-group', '[class]': 'classes()' }, }) export class RadioGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('group/radio-group grid gap-3', this.class()), ); } /** * One option of a {@link RadioGroup}, on a native `button`, with a * `ndRadioGroupIndicator` inside. Name it with a `label[ndLabel]` whose * `for` is its `id`. * * @example * ```html * <button ndRadioGroupItem value="pro" id="pro"> * <span ndRadioGroupIndicator><svg ndIcon="circle"></svg></span> * </button> * ``` */ @Directive({ selector: 'button[ndRadioGroupItem]', hostDirectives: [ { directive: NdRadioGroupItem, inputs: ['value', 'disabled'] }, ], host: { 'data-slot': 'radio-group-item', '[class]': 'classes()' }, }) export class RadioGroupItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40', 'group-aria-invalid/radio-group:border-destructive', this.class(), ), ); } /** * The dot of a {@link RadioGroupItem}, shown while it's checked. Put a * circle icon inside. * * @example * ```html * <span ndRadioGroupIndicator><svg ndIcon="circle"></svg></span> * ``` */ @Directive({ selector: '[ndRadioGroupIndicator]', hostDirectives: [NdRadioGroupIndicator], host: { 'data-slot': 'radio-group-indicator', '[class]': 'classes()' }, }) export class RadioGroupIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex items-center justify-center [&>svg]:size-2 [&>svg]:fill-primary', this.class(), ), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import { Label } from '@/ui/label/label';
import {
RadioGroup,
RadioGroupIndicator,
RadioGroupItem,
} from '@/ui/radio-group/radio-group';<div ndRadioGroup aria-label="Density" defaultValue="comfortable">
@for (option of options; track option.value) {
<div class="flex items-center gap-3">
<button
ndRadioGroupItem
[value]="option.value"
[id]="'radio-group-demo-' + option.value"
>
<span ndRadioGroupIndicator><svg ndIcon="circle"></svg></span>
</button>
<label ndLabel [for]="'radio-group-demo-' + option.value">
{{ option.label }}
</label>
</div>
}
</div>Examples
Radio Group cards
Plans as selectable cards, each with a description.
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
} from '@/ui/form-field/form-field';
import {
RadioGroup,
RadioGroupIndicator,
RadioGroupItem,
} from '@/ui/radio-group/radio-group';
/**
* Plans as selectable cards: each card is the label of its radio, so
* clicking anywhere on it chooses the plan, and the chosen card is
* highlighted.
*/
@Component({
selector: 'example-radio-group-cards',
imports: [
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
NdIcon,
RadioGroup,
RadioGroupIndicator,
RadioGroupItem,
],
template: `
<div
ndRadioGroup
aria-label="Plan"
class="w-full max-w-sm"
[(value)]="plan"
>
@for (option of plans; track option.value) {
<label ndFieldLabel [for]="'radio-group-cards-' + option.value">
<div ndField orientation="horizontal">
<div ndFieldContent>
<div ndFieldTitle>{{ option.title }}</div>
<p ndFieldDescription>{{ option.description }}</p>
</div>
<button
ndRadioGroupItem
[value]="option.value"
[id]="'radio-group-cards-' + option.value"
[attr.aria-label]="option.title"
>
<span ndRadioGroupIndicator><svg ndIcon="circle"></svg></span>
</button>
</div>
</label>
}
</div>
`,
})
export class RadioGroupCardsExample {
protected readonly plans = [
{
value: 'starter',
title: 'Starter',
description: 'For side projects: one seat, 5 GB of storage.',
},
{
value: 'pro',
title: 'Pro',
description: 'For small teams: ten seats, 100 GB of storage.',
},
{
value: 'business',
title: 'Business',
description: 'Unlimited seats, single sign-on and audit logs.',
},
];
protected readonly plan = signal<string | null>('pro');
}
Radio Group disabled
A group with one option that can't be chosen.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Label } from '@/ui/label/label';
import {
RadioGroup,
RadioGroupIndicator,
RadioGroupItem,
} from '@/ui/radio-group/radio-group';
/**
* A group with one option that can't be chosen; the arrow keys skip it.
*/
@Component({
selector: 'example-radio-group-disabled',
imports: [Label, NdIcon, RadioGroup, RadioGroupIndicator, RadioGroupItem],
template: `
<div ndRadioGroup aria-label="Delivery" defaultValue="standard">
@for (option of options; track option.value) {
<div class="flex items-center gap-3">
<button
ndRadioGroupItem
[value]="option.value"
[id]="'radio-group-disabled-' + option.value"
[disabled]="option.disabled"
>
<span ndRadioGroupIndicator><svg ndIcon="circle"></svg></span>
</button>
<label
ndLabel
[for]="'radio-group-disabled-' + option.value"
class="data-disabled:opacity-50"
>
{{ option.label }}
</label>
</div>
}
</div>
`,
})
export class RadioGroupDisabledExample {
protected readonly options = [
{ value: 'standard', label: 'Standard, 3–5 days', disabled: false },
{ value: 'express', label: 'Express, next day', disabled: false },
{ value: 'same-day', label: 'Same day (unavailable)', disabled: true },
];
}
Radio Group form
A required choice in a Signal Forms form, with its error once touched.
import { Component, signal } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import {
Field,
FieldError,
FieldLegend,
FieldSet,
} from '@/ui/form-field/form-field';
import { Label } from '@/ui/label/label';
import {
RadioGroup,
RadioGroupIndicator,
RadioGroupItem,
} from '@/ui/radio-group/radio-group';
/**
* A required choice in a Signal Forms form. Submitting without one shows
* the error and announces it.
*/
@Component({
selector: 'example-radio-group-form',
imports: [
Button,
Field,
FieldError,
FieldLegend,
FieldSet,
FormField,
Label,
NdIcon,
RadioGroup,
RadioGroupIndicator,
RadioGroupItem,
],
template: `
<form class="flex flex-col gap-6" (submit)="submit($event)">
<fieldset ndFieldSet>
<legend ndFieldLegend variant="label" id="radio-group-form-legend">
Notify me about
</legend>
<div ndField>
<div
ndRadioGroup
aria-labelledby="radio-group-form-legend"
[formField]="preferences.notify"
>
@for (option of options; track option.value) {
<div class="flex items-center gap-3">
<button
ndRadioGroupItem
[value]="option.value"
[id]="'radio-group-form-' + option.value"
>
<span ndRadioGroupIndicator>
<svg ndIcon="circle"></svg>
</span>
</button>
<label ndLabel [for]="'radio-group-form-' + option.value">
{{ option.label }}
</label>
</div>
}
</div>
<p ndFieldError>Choose what to be notified about.</p>
</div>
</fieldset>
<button ndButton type="submit" class="self-start">Save</button>
</form>
`,
})
export class RadioGroupFormExample {
protected readonly options = [
{ value: 'all', label: 'All new messages' },
{ value: 'mentions', label: 'Direct messages and mentions' },
{ value: 'none', label: 'Nothing' },
];
protected readonly model = signal<{ notify: string | null }>({
notify: null,
});
protected readonly preferences = form(this.model, (path) => {
required(path.notify);
});
/**
* Marks the fields touched, so errors show, instead of reloading.
*
* @param event - The form's submit event.
*/
protected submit(event: Event): void {
event.preventDefault();
this.preferences().markAsTouched();
}
}
Accessibility
Implements the Radio Group pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| Tab | Moves focus into the group, to the checked item or, if none, the first; then out of it. |
| Space | Checks the focused item, if it isn't checked. |
| Enter | Does nothing, as for native radio buttons; the default is prevented. |
| ArrowDownorArrowRight | Moves focus to the next enabled item and checks it; from the last, wraps to the first. |
| ArrowUporArrowLeft | Moves focus to the previous enabled item and checks it; from the first, wraps to the last. |
Focus
- Arrow keys move focus and the selection together; disabled items are skipped.
- Clicking an item focuses and checks it.
- Focus leaving the group marks a bound field touched.
Right-to-left
In right-to-left layouts, ArrowLeft moves to the next item and ArrowRight to the previous one. Direction comes from the nearest dir attribute, on the item or an ancestor.
API reference
RadioGroup
[ndRadioGroup]role radiogroupA radio group (@ndop/primitives/radio-group): options of which one at
most is checked, with the arrow keys moving between them. Bind it with
[(value)] or any forms library, and name it with aria-label or
aria-labelledby.
Manages aria-orientation, aria-required, aria-invalid, aria-disabled.
| RadioGroup binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[defaultValue]Initial value when it isn't bound. | string | null | null |
[orientation]The items' layout, for aria-orientation and data-orientation. All
four arrow keys work either way. | 'horizontal' | 'vertical' | 'vertical' |
[disabled]Disables every item. A disabled bound field does too. | boolean | false |
[required]Marks the group required: aria-required="true". | boolean | false |
[invalid]Marks the group invalid when no forms library is bound; with one, the
field's state decides. | boolean | false |
[(value)]The checked item's value, or null when none is checked. Two-way
bindable with [(value)]. | string | null | null |
| Data attribute | Values |
|---|---|
data-slot | radio-group |
data-orientation | — |
data-disabled | — |
data-invalid | — |
RadioGroupItem
button[ndRadioGroupItem]role radioOne option of a RadioGroup, on a native button, with a
ndRadioGroupIndicator inside. Name it with a label[ndLabel] whose
for is its id.
Manages aria-checked.
| RadioGroupItem binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[value]requiredThe value the group takes when this item is checked. | string | — |
[disabled]Disables this item; arrow keys skip it. | boolean | false |
| Data attribute | Values |
|---|---|
data-slot | radio-group-item |
data-state | — |
data-disabled | — |
data-orientation | — |
RadioGroupIndicator
[ndRadioGroupIndicator]The dot of a RadioGroupItem, shown while it's checked. Put a
circle icon inside.
| RadioGroupIndicator binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | radio-group-indicator |
data-state | — |
Agent notes
Do
- Name the group with
aria-labelledbypointing at its heading or legend, and each option with a label. - Start with an option chosen when there's a sensible default.
- Keep the options short, and few.
Don't
- Don't use a radio group for more than about seven options; use a Select.
- Don't use it for options that can be combined; use checkboxes.