Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Radio

A selection control for choosing one option from a set.

A group of radios for choosing a single option from a set. Android shows an MD3 ring with an animated dot; iOS uses a plain checkmark (no circle). Arrow keys move the selection within the group per the WAI-ARIA radio pattern.

Installation

npx touchcn add radio

Usage

<tcn-radio-group [(value)]="plan">
  <tcn-radio value="free">Free</tcn-radio>
  <tcn-radio value="pro">Pro</tcn-radio>
</tcn-radio-group>
import { TcnRadio, TcnRadioGroup } from '@/components/ui/radio';

<TcnRadioGroup value={plan} onValueChange={setPlan}>
  <TcnRadio value="free">Free</TcnRadio>
  <TcnRadio value="pro">Pro</TcnRadio>
</TcnRadioGroup>
<script setup lang="ts">
import { TcnRadio, TcnRadioGroup } from '@/components/ui/radio';
</script>

<template>
  <TcnRadioGroup v-model="plan">
    <TcnRadio value="free">Free</TcnRadio>
    <TcnRadio value="pro">Pro</TcnRadio>
  </TcnRadioGroup>
</template>

Props

RadioGroup

Prop Type Default Description
value string | null null Selected value (two-way bound).
onValueChange (value: string) => void Change callback (React).
disabled boolean false Disable the whole group.

Radio

Prop Type Default Description
value string The option’s value (required).
disabled boolean false Disable this option.

On React, TcnRadioGroup / TcnRadio forward Radix RadioGroup props.

Source · Angular
export * from './tcn-radio';
import {
  booleanAttribute,
  Component,
  computed,
  contentChildren,
  ElementRef,
  inject,
  input,
  model,
  viewChild,
} from '@angular/core';
import { TcnRadioKeyNavDirective } from '@touchcn/angular/radio';

/**
 * Single-selection radio group with a value model. The group owns the selected
 * value and the roving-`tabindex` decision (WAI-ARIA radio pattern: only the
 * selected radio — or the first enabled one when nothing is selected — is
 * tabbable). Arrow-key focus movement is delegated to the engine directive
 * `TcnRadioKeyNav`, which performs the DOM focus and emits the radio to select.
 */
@Component({
  selector: 'tcn-radio-group',
  imports: [TcnRadioKeyNavDirective],
  template: `
    <div
      role="radiogroup"
      class="tcn-radio-group flex flex-col gap-1"
      tcnRadioKeyNav
      [items]="elements()"
      (activate)="activate($event)"
      [attr.aria-disabled]="disabled() || null"
    >
      <ng-content />
    </div>
  `,
  host: { class: 'block' },
})
export class TcnRadioGroup {
  readonly value = model<string | null>(null);
  readonly disabled = input(false, { transform: booleanAttribute });

  private readonly radios = contentChildren(TcnRadio);

  protected readonly elements = computed(() =>
    this.radios()
      .map((radio) => radio.buttonElement())
      .filter((element): element is HTMLButtonElement => !!element),
  );

  /** The value whose radio is currently tabbable (selected, else first enabled). */
  readonly focusableValue = computed<string | null>(() => {
    const selected = this.value();
    if (selected != null && this.radios().some((radio) => radio.value() === selected && !radio.isDisabled())) {
      return selected;
    }
    const firstEnabled = this.radios().find((radio) => !radio.isDisabled());
    return firstEnabled ? firstEnabled.value() : null;
  });

  select(value: string): void {
    if (this.disabled()) {
      return;
    }
    this.value.set(value);
  }

  protected activate(element: HTMLElement): void {
    const radio = this.radios().find((candidate) => candidate.buttonElement() === element);
    if (radio) {
      this.select(radio.value());
    }
  }
}

/**
 * A single radio. Renders a `role="radio"` button whose indicator forks by
 * platform (`.if-md` ring + dot / `.if-ios` checkmark). Selection state and the
 * roving `tabindex` are derived from the parent group's signals.
 */
@Component({
  selector: 'tcn-radio',
  template: `
    <button
      #button
      type="button"
      role="radio"
      class="tcn-radio"
      [attr.aria-checked]="checked()"
      [attr.data-state]="checked() ? 'checked' : 'unchecked'"
      [attr.tabindex]="tabindex()"
      [disabled]="isDisabled()"
      (click)="select()"
    >
      <span class="tcn-radio-circle if-md">
        <span class="tcn-radio-state" aria-hidden="true"></span>
        <span class="tcn-radio-dot"></span>
      </span>
      <span class="tcn-radio-check if-ios" aria-hidden="true">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
          <path d="M5 12l5 5L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
        </svg>
      </span>
      <ng-content />
    </button>
  `,
  host: { class: 'block' },
})
export class TcnRadio {
  private readonly group = inject(TcnRadioGroup);
  private readonly button = viewChild<ElementRef<HTMLButtonElement>>('button');

  readonly value = input.required<string>();
  readonly disabled = input(false, { transform: booleanAttribute });

  readonly buttonElement = computed(() => this.button()?.nativeElement ?? null);
  readonly isDisabled = computed(() => this.disabled() || this.group.disabled());

  protected readonly checked = computed(() => this.group.value() === this.value());
  protected readonly tabindex = computed(() => {
    if (this.isDisabled()) {
      return -1;
    }
    return this.group.focusableValue() === this.value() ? 0 : -1;
  });

  protected select(): void {
    if (!this.isDisabled()) {
      this.group.select(this.value());
    }
  }
}
Source · React
export * from './tcn-radio';
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { cn } from '@touchcn/core';

export interface TcnRadioGroupProps
  extends Omit<ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>, 'asChild'> {}

export interface TcnRadioProps
  extends Omit<ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>, 'asChild'> {}

/**
 * Radio group. Radix supplies the `role="radiogroup"` container with roving
 * `tabindex`, arrow-key navigation and single-selection value model (the
 * behaviour the Angular engine directive re-implements).
 */
export const TcnRadioGroup = forwardRef<
  React.ElementRef<typeof RadioGroupPrimitive.Root>,
  TcnRadioGroupProps
>(function TcnRadioGroup({ className, children, ...props }, ref) {
  return (
    <RadioGroupPrimitive.Root ref={ref} className={cn('tcn-radio-group flex flex-col gap-1', className)} {...props}>
      {children}
    </RadioGroupPrimitive.Root>
  );
});

/**
 * A single radio. Radix renders the `button[role="radio"]` with `data-state`;
 * the indicator forks by platform via the cascade (`.if-md` ring + dot /
 * `.if-ios` checkmark). The label is passed as children so it renders inside
 * the button — one tap and focus target with the text as accessible name.
 */
export const TcnRadio = forwardRef<
  React.ElementRef<typeof RadioGroupPrimitive.Item>,
  TcnRadioProps
>(function TcnRadio({ className, children, ...props }, ref) {
  return (
    <RadioGroupPrimitive.Item ref={ref} className={cn('tcn-radio', className)} {...props}>
      <span className="tcn-radio-circle if-md">
        <span className="tcn-radio-state" aria-hidden="true" />
        <span className="tcn-radio-dot" />
      </span>
      <span className="tcn-radio-check if-ios" aria-hidden="true">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
          <path
            d="M5 12l5 5L19 7"
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      {children}
    </RadioGroupPrimitive.Item>
  );
});
Source · Vue
<script setup lang="ts">
import { RadioGroupItem } from 'reka-ui';

defineProps<{ value: string; disabled?: boolean }>();

/**
 * A single radio. reka-ui `RadioGroupItem` renders the `button[role="radio"]`
 * with `data-state`; the indicator forks by platform via the cascade (`.if-md`
 * ring + dot / `.if-ios` checkmark). The label is passed as the default slot so
 * it renders inside the button — one tap and focus target with the text as
 * accessible name.
 */
</script>

<template>
  <RadioGroupItem :value="value" :disabled="disabled" class="tcn-radio">
    <span class="tcn-radio-circle if-md">
      <span class="tcn-radio-state" aria-hidden="true" />
      <span class="tcn-radio-dot" />
    </span>
    <span class="tcn-radio-check if-ios" aria-hidden="true">
      <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
        <path
          d="M5 12l5 5L19 7"
          stroke="currentColor"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </span>
    <slot />
  </RadioGroupItem>
</template>
<script setup lang="ts">
import { RadioGroupRoot } from 'reka-ui';

defineProps<{ disabled?: boolean }>();

/**
 * Radio group. reka-ui `RadioGroupRoot` supplies the `role="radiogroup"`
 * container with roving `tabindex`, arrow-key navigation and the
 * single-selection value model (the behaviour the Angular engine directive
 * re-implements). Two-way bound with `v-model`.
 */
const model = defineModel<string>();
</script>

<template>
  <RadioGroupRoot v-model="model" :disabled="disabled" class="tcn-radio-group flex flex-col gap-1">
    <slot />
  </RadioGroupRoot>
</template>
export { default as TcnRadioGroup } from './TcnRadioGroup.vue';
export { default as TcnRadio } from './TcnRadio.vue';

Last updated on July 24, 2026

Was this page helpful?