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';