Skip to content
Ndop UI

Radio Group

A set of options of which one at most is chosen, navigated with the arrow keys.

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-group

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.

Notify me about
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
TabMoves focus into the group, to the checked item or, if none, the first; then out of it.
SpaceChecks the focused item, if it isn't checked.
EnterDoes nothing, as for native radio buttons; the default is prevented.
ArrowDownorArrowRightMoves focus to the next enabled item and checks it; from the last, wraps to the first.
ArrowUporArrowLeftMoves 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 radiogroup

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.

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 | nullnull
[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.
booleanfalse
[required]
Marks the group required: aria-required="true".
booleanfalse
[invalid]
Marks the group invalid when no forms library is bound; with one, the field's state decides.
booleanfalse
[(value)]
The checked item's value, or null when none is checked. Two-way bindable with [(value)].
string | nullnull
Data attribute Values
data-slotradio-group
data-orientation —
data-disabled —
data-invalid —

RadioGroupItem

button[ndRadioGroupItem]role radio

One 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]required
The value the group takes when this item is checked.
string—
[disabled]
Disables this item; arrow keys skip it.
booleanfalse
Data attribute Values
data-slotradio-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-slotradio-group-indicator
data-state —

Agent notes

Do

  • Name the group with aria-labelledby pointing 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.