Radio Group

A set of radio buttons with arrow-key navigation that works with Angular forms.

import { โ€ฆ } from '@inlaylab/ivorie/radio-group'
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { IV_RADIO_GROUP_IMPORTS } from '@inlaylab/ivorie/radio-group';

@Component({
  selector: 'demo-radio-group',
  imports: [IV_RADIO_GROUP_IMPORTS],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <iv-radio-group [(value)]="plan" class="w-full max-w-sm">
      @for (p of plans; track p.value) {
        <label
          class="has-[[data-checked]]:border-primary has-[[data-checked]]:ring-primary/20 flex cursor-pointer items-start gap-3 rounded-lg border p-4 has-[[data-checked]]:ring-[3px]"
        >
          <iv-radio-group-item [value]="p.value" class="mt-1" />
          <span class="grid flex-1 gap-1">
            <span class="flex items-baseline justify-between gap-2">
              <span class="font-medium">{{ p.name }}</span>
              <span class="text-muted-foreground text-sm">{{ p.price }}</span>
            </span>
            <span class="text-muted-foreground text-sm">{{ p.description }}</span>
          </span>
        </label>
      }
    </iv-radio-group>
  `,
})
export default class RadioGroupDemo {
  protected readonly plans = [
    { value: 'starter', name: 'Starter', price: '$0', description: 'For trying things out. Up to 3 projects.' },
    { value: 'pro', name: 'Pro', price: '$12/mo', description: 'For small teams. Unlimited projects, priority support.' },
    { value: 'team', name: 'Team', price: '$49/mo', description: 'For growing teams. Shared workspaces and roles.' },
  ];
  protected readonly plan = signal<string | null>('pro');
}

Installation

CLInpm
npx ivorie add radio-group

A group of native <button role="radio"> items. It supports ngModel, reactive forms and plain two-way binding.

Usage

import { IV_RADIO_GROUP_IMPORTS } from '@inlaylab/ivorie/radio-group';
<iv-radio-group [(value)]="plan">
  <iv-radio-group-item value="starter" aria-label="Starter" />
  <iv-radio-group-item value="pro" aria-label="Pro" />
</iv-radio-group>

Examples

With ngModel

Value: starter

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { IV_RADIO_GROUP_IMPORTS } from '@inlaylab/ivorie/radio-group';

@Component({
  selector: 'demo-radio-group-ngmodel',
  imports: [FormsModule, IV_RADIO_GROUP_IMPORTS],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="flex flex-col gap-3">
      <iv-radio-group [(ngModel)]="plan" class="gap-2">
        <iv-radio-group-item value="starter" aria-label="Starter" />
        <iv-radio-group-item value="pro" aria-label="Pro" />
        <iv-radio-group-item value="team" aria-label="Team" />
      </iv-radio-group>
      <p class="text-muted-foreground text-sm">Value: {{ plan() }}</p>
    </div>
  `,
})
export default class RadioGroupNgmodelDemo {
  protected readonly plan = signal<string | null>('starter');
}

Reactive forms

Value: starter

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { IV_RADIO_GROUP_IMPORTS } from '@inlaylab/ivorie/radio-group';

@Component({
  selector: 'demo-radio-group-reactive-forms',
  imports: [ReactiveFormsModule, IV_RADIO_GROUP_IMPORTS],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <form [formGroup]="form" class="flex flex-col gap-3">
      <iv-radio-group formControlName="plan" class="gap-2">
        <iv-radio-group-item value="starter" aria-label="Starter" />
        <iv-radio-group-item value="pro" aria-label="Pro" />
        <iv-radio-group-item value="team" aria-label="Team" />
      </iv-radio-group>
      <p class="text-muted-foreground text-sm">Value: {{ form.value.plan }}</p>
    </form>
  `,
})
export default class RadioGroupReactiveFormsDemo {
  protected readonly form = new FormGroup({ plan: new FormControl('starter') });
}

With labels

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { IV_RADIO_GROUP_IMPORTS } from '@inlaylab/ivorie/radio-group';

@Component({
  selector: 'demo-radio-group-with-labels',
  imports: [IV_RADIO_GROUP_IMPORTS],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <iv-radio-group [(value)]="plan" class="gap-3">
      @for (option of plans; track option.value) {
        <label class="flex cursor-pointer items-center gap-2 text-sm">
          <iv-radio-group-item [value]="option.value" />
          {{ option.label }}
        </label>
      }
    </iv-radio-group>
  `,
})
export default class RadioGroupWithLabelsDemo {
  protected readonly plans = [
    { value: 'starter', label: 'Starter' },
    { value: 'pro', label: 'Pro' },
    { value: 'team', label: 'Team' },
  ];
  protected readonly plan = signal<string | null>('pro');
}

Disabled group

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { IV_RADIO_GROUP_IMPORTS } from '@inlaylab/ivorie/radio-group';

@Component({
  selector: 'demo-radio-group-disabled-group',
  imports: [IV_RADIO_GROUP_IMPORTS],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <iv-radio-group value="pro" [disabled]="true" class="gap-3">
      <label class="flex items-center gap-2 text-sm">
        <iv-radio-group-item value="starter" />
        Starter
      </label>
      <label class="flex items-center gap-2 text-sm">
        <iv-radio-group-item value="pro" />
        Pro
      </label>
    </iv-radio-group>
  `,
})
export default class RadioGroupDisabledGroupDemo {}

Disabled item

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { IV_RADIO_GROUP_IMPORTS } from '@inlaylab/ivorie/radio-group';

@Component({
  selector: 'demo-radio-group-disabled-item',
  imports: [IV_RADIO_GROUP_IMPORTS],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <iv-radio-group [(value)]="plan" class="gap-3">
      <label class="flex cursor-pointer items-center gap-2 text-sm">
        <iv-radio-group-item value="starter" />
        Starter
      </label>
      <label class="flex items-center gap-2 text-sm">
        <iv-radio-group-item value="enterprise" [disabled]="true" />
        Enterprise (unavailable)
      </label>
    </iv-radio-group>
  `,
})
export default class RadioGroupDisabledItemDemo {
  protected readonly plan = signal<string | null>('starter');
}

API

Every part also accepts class.

iv-radio-group

Name Type Default Description
value string | null null Selected value. Bind with [(value)].
disabled boolean false Disables all items. Also disabled when the form control is disabled.
orientation 'horizontal' | 'vertical' | undefined undefined Sets aria-orientation.

Outputs:

Name Payload Description
valueChange string | null Emits when the user selects an item.

iv-radio-group-item

Must be a descendant of iv-radio-group. Renders a native button; class goes on that button.

Name Type Default Description
value string required Value this item stands for.
disabled boolean false Disables this item only.
id string | null null Set on the inner button, so <label for> works.
aria-label string | null null Set on the inner button.
aria-labelledby string | null null Set on the inner button.

Accessibility

  • The group has role="radiogroup". Each item is <button role="radio"> with aria-checked.
  • Only one item is in the tab order (roving tabindex): the checked item, else the first enabled one.
  • Arrow Down and Arrow Right select the next enabled item. Arrow Up and Arrow Left select the previous one. Both wrap at the ends and skip disabled items.
  • Space selects the focused item. Enter does nothing, as in Radix.
  • A wrapping <label> and <label for> both work.

Notes

  • ngModel and formControlName need FormsModule or ReactiveFormsModule.
  • Each item button has data-state, data-checked, data-unchecked and data-disabled.
  • Setting the value from the form does not emit valueChange.