bpdm/ui
Form

Radio Group

A single-choice group — sizes, orientation, invalid state, accessible, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Radio Group lets users pick exactly one option from a set (use a checkbox group for multiple). Built on Radix — controlled (value + onValueChange) or uncontrolled (defaultValue). In Angular it's <bpdm-radio-group> with <bpdm-radio> items, driven by [(value)], [(ngModel)], or reactive forms.

Usage

Pair each RadioGroupItem with a <label> linked by id so clicking the text selects the option.

radio-usage.tsx
import { RadioGroup, RadioGroupItem } from '@bpdm/ui/radio-group';

export function PlanPicker() {
  return (
    <RadioGroup defaultValue="pro">
      <div className="flex items-center gap-2.5">
        <RadioGroupItem value="free" id="free" />
        <label htmlFor="free">Free</label>
      </div>
      <div className="flex items-center gap-2.5">
        <RadioGroupItem value="pro" id="pro" />
        <label htmlFor="pro">Pro</label>
      </div>
      <div className="flex items-center gap-2.5">
        <RadioGroupItem value="enterprise" id="enterprise" />
        <label htmlFor="enterprise">Enterprise</label>
      </div>
    </RadioGroup>
  );
}
radio-usage.ts
import { Component } from '@angular/core';
import { BpdmRadioGroup, BpdmRadio } from '@bpdm/ng';

@Component({
  selector: 'plan-picker',
  imports: [BpdmRadioGroup, BpdmRadio],
  templateUrl: './radio-usage.html',
})
export class PlanPicker {}
radio-usage.html
<bpdm-radio-group [(value)]="plan">
  <label class="flex items-center gap-2.5">
    <bpdm-radio value="free" /> Free
  </label>
  <label class="flex items-center gap-2.5">
    <bpdm-radio value="pro" /> Pro
  </label>
  <label class="flex items-center gap-2.5">
    <bpdm-radio value="enterprise" /> Enterprise
  </label>
</bpdm-radio-group>

Orientation

Vertical by default; set orientation="horizontal" to lay items in a row.

radio-horizontal.tsx
import { RadioGroup, RadioGroupItem } from '@bpdm/ui/radio-group';

export function HorizontalPlan() {
  return (
    <RadioGroup defaultValue="pro" orientation="horizontal">
      <label className="flex items-center gap-2"><RadioGroupItem value="free" /> Free</label>
      <label className="flex items-center gap-2"><RadioGroupItem value="pro" /> Pro</label>
      <label className="flex items-center gap-2"><RadioGroupItem value="enterprise" /> Enterprise</label>
    </RadioGroup>
  );
}
radio-horizontal.component.ts
import { Component } from '@angular/core';
import { BpdmRadioGroup, BpdmRadio } from '@bpdm/ng';

@Component({
  selector: 'app-horizontal-plan',
  standalone: true,
  imports: [BpdmRadioGroup, BpdmRadio],
  template: `
    <bpdm-radio-group [(value)]="plan" orientation="horizontal">
      <label class="flex items-center gap-2"><bpdm-radio value="free" /> Free</label>
      <label class="flex items-center gap-2"><bpdm-radio value="pro" /> Pro</label>
      <label class="flex items-center gap-2"><bpdm-radio value="enterprise" /> Enterprise</label>
    </bpdm-radio-group>
  `,
})
export class HorizontalPlanComponent {
  plan = 'pro';
}

Sizes

The item size prop offers sm, md (default), and lg.

radio-sizes.tsx
import { RadioGroup, RadioGroupItem } from '@bpdm/ui/radio-group';

export function Sizes() {
  return (
    <RadioGroup defaultValue="md" orientation="horizontal" aria-label="Size">
      <RadioGroupItem value="sm" size="sm" aria-label="Small" />
      <RadioGroupItem value="md" size="md" aria-label="Medium" />
      <RadioGroupItem value="lg" size="lg" aria-label="Large" />
    </RadioGroup>
  );
}
radio-sizes.component.ts
import { Component } from '@angular/core';
import { BpdmRadioGroup, BpdmRadio } from '@bpdm/ng';

@Component({
  selector: 'app-sizes',
  standalone: true,
  imports: [BpdmRadioGroup, BpdmRadio],
  template: `
    <bpdm-radio-group [(value)]="size" orientation="horizontal" aria-label="Size">
      <bpdm-radio value="sm" size="sm" aria-label="Small" />
      <bpdm-radio value="md" size="md" aria-label="Medium" />
      <bpdm-radio value="lg" size="lg" aria-label="Large" />
    </bpdm-radio-group>
  `,
})
export class SizesComponent {
  size = 'md';
}

Disabled

Set disabled on the group to disable every option at once.

radio-disabled.tsx
import { RadioGroup, RadioGroupItem } from '@bpdm/ui/radio-group';

export function DisabledPlan() {
  return (
    <RadioGroup defaultValue="pro" disabled>
      <label className="flex items-center gap-2"><RadioGroupItem value="free" /> Free</label>
      <label className="flex items-center gap-2"><RadioGroupItem value="pro" /> Pro</label>
      <label className="flex items-center gap-2"><RadioGroupItem value="enterprise" /> Enterprise</label>
    </RadioGroup>
  );
}
radio-disabled.component.ts
import { Component } from '@angular/core';
import { BpdmRadioGroup, BpdmRadio } from '@bpdm/ng';

@Component({
  selector: 'app-disabled-plan',
  standalone: true,
  imports: [BpdmRadioGroup, BpdmRadio],
  template: `
    <bpdm-radio-group [(value)]="plan" disabled>
      <label class="flex items-center gap-2"><bpdm-radio value="free" /> Free</label>
      <label class="flex items-center gap-2"><bpdm-radio value="pro" /> Pro</label>
      <label class="flex items-center gap-2"><bpdm-radio value="enterprise" /> Enterprise</label>
    </bpdm-radio-group>
  `,
})
export class DisabledPlanComponent {
  plan = 'pro';
}

Invalid

Set aria-invalid on the items to show the error state — the controls turn destructive-coloured and screen readers announce the invalid state.

radio-invalid.tsx
import { RadioGroup, RadioGroupItem } from '@bpdm/ui/radio-group';

export function InvalidPlan() {
  return (
    <div className="flex flex-col gap-1.5">
      <RadioGroup aria-label="Plan" aria-describedby="plan-err">
        <label className="flex items-center gap-2"><RadioGroupItem value="free" aria-invalid /> Free</label>
        <label className="flex items-center gap-2"><RadioGroupItem value="pro" aria-invalid /> Pro</label>
      </RadioGroup>
      <p id="plan-err" className="text-xs text-destructive">Select a plan to continue.</p>
    </div>
  );
}
radio-invalid.component.ts
import { Component } from '@angular/core';
import { BpdmRadioGroup, BpdmRadio } from '@bpdm/ng';

@Component({
  selector: 'app-invalid-plan',
  standalone: true,
  imports: [BpdmRadioGroup, BpdmRadio],
  template: `
    <div class="flex flex-col gap-1.5">
      <bpdm-radio-group [(value)]="plan" aria-label="Plan" aria-describedby="plan-err">
        <label class="flex items-center gap-2"><bpdm-radio value="free" aria-invalid="true" /> Free</label>
        <label class="flex items-center gap-2"><bpdm-radio value="pro" aria-invalid="true" /> Pro</label>
      </bpdm-radio-group>
      <p id="plan-err" class="text-xs text-destructive">Select a plan to continue.</p>
    </div>
  `,
})
export class InvalidPlanComponent {
  plan = '';
}

Controlled value

Drive the selection yourself with value + onValueChange (React) or two-way [(value)] / [(ngModel)] (Angular).

Selected: pro

radio-controlled.tsx
import { useState } from 'react';
import { RadioGroup, RadioGroupItem } from '@bpdm/ui/radio-group';

export function Controlled() {
  const [value, setValue] = useState('pro');
  return (
    <RadioGroup value={value} onValueChange={setValue} orientation="horizontal">
      <label className="flex items-center gap-2"><RadioGroupItem value="free" /> Free</label>
      <label className="flex items-center gap-2"><RadioGroupItem value="pro" /> Pro</label>
      <label className="flex items-center gap-2"><RadioGroupItem value="enterprise" /> Enterprise</label>
    </RadioGroup>
  );
}
radio-controlled.component.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BpdmRadioGroup, BpdmRadio } from '@bpdm/ng';

@Component({
  selector: 'app-controlled-plan',
  standalone: true,
  imports: [FormsModule, BpdmRadioGroup, BpdmRadio],
  // two-way [(value)] shown here; [(ngModel)] / reactive forms work the same way
  template: `
    <bpdm-radio-group [(value)]="plan">
      <label class="flex items-center gap-2.5"><bpdm-radio value="free" /> Free</label>
      <label class="flex items-center gap-2.5"><bpdm-radio value="pro" /> Pro</label>
      <label class="flex items-center gap-2.5"><bpdm-radio value="enterprise" /> Enterprise</label>
    </bpdm-radio-group>
    <p class="mt-2 text-sm text-muted-foreground">Selected: {{ plan }}</p>
  `,
})
export class ControlledPlanComponent {
  plan = 'pro';
}

API

RadioGroup / <bpdm-radio-group>

PropTypeDefaultDescription
value / defaultValuestringControlled / uncontrolled selection.
onValueChange(value: string) => voidReact — fires on change. Angular uses [(value)] / [(ngModel)].
orientationvertical | horizontalverticalLayout direction.
disabledbooleanfalseDisable the whole group.
namestringForm field name (React).

RadioGroupItem / <bpdm-radio>

PropTypeDefaultDescription
valuestringRequired. The item's value.
sizesm | md | lgmdControl size.
disabledbooleanfalseDisable just this option (skipped by arrow-key navigation).
aria-invalidbooleanError state.
idstringControl id (label association / testing), forwarded to the radio.
aria-label / aria-labelledby / aria-describedbystringAccessible name / description, forwarded to the radio.

In React, all native Radix RadioGroup props (name, dir, aria-*, data-*, …) are spread and refs are forwarded. In Angular, orientation / disabled are @Inputs on <bpdm-radio-group> (a ControlValueAccessor), and value / size / disabled / aria-invalid / id / aria-label / aria-labelledby / aria-describedby on <bpdm-radio>.

Accessibility

  • A proper role="radiogroup" of role="radio" items with roving tabindex — the group is a single tab stop, and arrow keys move focus and selection between options (wrapping, skipping disabled ones); Space selects. Implemented in both frameworks (React via Radix, Angular natively).
  • Give each item an accessible name — link a <label> by id (htmlFor in React, for in Angular), wrap the control in the label, or pass aria-label / aria-labelledby (forwarded to the control in both frameworks).
  • Give the group an accessible name with aria-label / aria-labelledby when there's no visible group heading.
  • Set aria-invalid on items that fail validation; bpdm styles them destructive-red and assistive tech announces the state. The selected dot is decorative.

Internationalization

  • The radio group renders no text of its own — pass translated accessible names (item aria-label / associated <label>, and a group aria-label) and translated help/error via aria-describedby. There's nothing hard-coded to override.
  • RTL-safe: the controls are symmetric circles with no physical spacing, so the group mirrors under dir="rtl", and the arrow keys follow the visual order — ArrowLeft advances in a right-to-left layout. In Angular this is automatic from the ambient dir; in React pass dir="rtl" to <RadioGroup> (or use a Radix DirectionProvider). See the Internationalization guide.

On this page