Skip to content

Radio

Radio represents one choice from a short visible set. It renders a real input[type="radio"], so the browser keeps mutual exclusion, arrow-key navigation, clickable labels, required validation, submitted values, disabled behavior, and form reset.

The application supplies the shared name, semantic fieldset and legend, visible labels, descriptions, validation copy, and Tailwind styling. Klean does not duplicate HTML's group contract.

Radio.vue
Choose with a pointer, activate a label, or focus the checked choice and use the Arrow keys.

Installation

One command detects Vue, React, or Svelte and installs one framework-native source file:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add radio

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

The installation creates no provider, initializer, klean-ui.json, alias questionnaire, generated class helper, or Klean runtime dependency.

Usage

Vue

RegionField.vue
<script setup>
import { ref } from 'vue'
import Radio from '@/components/ui/radio/Radio.vue'

const region = ref('lagos')
</script>

<template>
  <fieldset>
    <legend class="font-medium">Deployment region</legend>
    <label class="mt-3 flex cursor-pointer items-center gap-3">
      <Radio v-model="region" name="region" value="lagos" required />
      Lagos
    </label>
    <label class="mt-3 flex cursor-pointer items-center gap-3">
      <Radio v-model="region" name="region" value="frankfurt" required />
      Frankfurt
    </label>
  </fieldset>
</template>

React

RegionField.jsx
import { useState } from 'react'
import Radio from '@/components/ui/radio/Radio.jsx'

export default function RegionField() {
  const [region, setRegion] = useState('lagos')

  return (
    <fieldset>
      <legend className="font-medium">Deployment region</legend>
      {['lagos', 'frankfurt'].map((value) => (
        <label key={value} className="mt-3 flex cursor-pointer gap-3">
          <Radio
            name="region"
            value={value}
            checked={region === value}
            required
            onChange={(event) => setRegion(event.target.value)}
          />
          {value}
        </label>
      ))}
    </fieldset>
  )
}

Svelte

RegionField.svelte
<script>
  import Radio from '$lib/components/ui/radio/Radio.svelte'

  let region = $state('lagos')
</script>

<fieldset>
  <legend class="font-medium">Deployment region</legend>
  <label class="mt-3 flex cursor-pointer items-center gap-3">
    <Radio bind:group={region} name="region" value="lagos" required />
    Lagos
  </label>
  <label class="mt-3 flex cursor-pointer items-center gap-3">
    <Radio bind:group={region} name="region" value="frankfurt" required />
    Frankfurt
  </label>
</fieldset>

The framework binding changes, but every version produces one native group and submits the checked value under region.

Why Radio, not RadioGroup?

HTML already groups radios that share a name. The browser keeps one checked, moves within the group with Arrow keys, validates required, submits one value, and restores the initial choice on form reset.

A JavaScript RadioGroup would duplicate those rules and create another place for state, orientation, items, labels, and focus to drift. Klean instead gives each framework its natural scalar binding:

  • Vue: one v-model shared by each Radio.
  • React: native checked, defaultChecked, value, and onChange.
  • Svelte: one bind:group shared by each Radio.

The fieldset is the group component. The legend is its accessible name.

When to use

Use Radio when a person must choose exactly one option from a short list that is helpful to compare at once: deployment region, participation policy, billing cadence, visibility, or storage provider.

Radio is especially useful when the labels or descriptions influence the decision and should remain visible without opening another surface.

When not to use

Use Checkbox when values are independent or several may be chosen. Use Switch for an immediate on/off setting. Use Select when one choice comes from a longer fixed list and compactness matters. Use Combobox when the list must be searched.

Do not use Radio for actions. A selected radio changes form state; a Button performs a command.

Semantic groups

Use one fieldset, one visible legend, and the same name for every related Radio:

vue
<fieldset aria-describedby="participation-help participation-error">
  <legend class="font-medium">Participation</legend>
  <p id="participation-help" class="text-sm text-gray-500">
    Choose who may submit and vote.
  </p>

  <label>
    <Radio
      v-model="form.allowAnonymousParticipation"
      name="participation"
      :value="false"
      required
    />
    Logged-in users only
  </label>

  <label>
    <Radio
      v-model="form.allowAnonymousParticipation"
      name="participation"
      :value="true"
      required
    />
    Anyone
  </label>

  <p id="participation-error" class="empty:hidden text-sm text-red-700">
    {{ form.errors.allowAnonymousParticipation }}
  </p>
</fieldset>

Vue retains typed values, including the real boolean participation choice used by Slipway. The application owns deterministic IDs, validation timing, and error copy.

Native behavior

  • Activating an associated label checks its Radio.
  • Arrow keys move and select within radios sharing a name.
  • Tab enters and leaves the group as one keyboard stop.
  • A checked Radio submits its name and value.
  • required makes the group participate in native constraint validation.
  • disabled prevents interaction and submission.
  • Form reset restores the initially checked choice.

Klean does not add key handlers, roving focus, role="radio", or aria-checked; all would duplicate the native input.

Slipway recipes

Slipway uses the same native control in three useful presentations. Klean keeps all three possible without adding visual variants.

Conventional provider list

Keep Radio visible when familiarity and quick scanning matter:

vue
<label class="flex cursor-pointer items-center gap-3 px-4 py-3">
  <Radio v-model="provider" name="provider" value="s3" />
  <span>
    <span class="block text-sm font-medium">Amazon S3</span>
    <span class="text-xs text-gray-500">Managed object storage</span>
  </span>
</label>

Choice cards

Visually hide only Radio and let the wrapping label present the large choice:

The selected style comes from has-[:checked]. Focus remains on the native Radio, so add focus-within:* utilities to the label when the product treatment needs a larger visible focus ring.

Filter chips

Radio is also suitable for one filter from a compact visible set:

Category

When a filter should survive reload and be shareable, synchronize the selected value with a query parameter. That URL policy belongs to the page, not Radio.

API

PurposeVueReactSvelte
Current valuev-modelchecked, onChangebind:group
Initial valueinitial modeldefaultCheckedinitial group
Choicevaluevaluevalue
Groupshared native nameshared native nameshared native name
Formnative input attributesnative input propsnative attributes
StylingclassclassNameclass

Radio exposes its native element for explicit focus recovery and stable data-slot="radio", data-state, data-disabled, and data-invalid hooks. It has no RadioGroup, item, indicator, orientation, variant, tone, size, label, or part-class props.

Styling

The default intentionally retains native radio rendering. Its accent follows the current text colour, and caller Tailwind merges last.

Control and label styling

Radio renders only the native input. Its class or className styles the control without styling the label text, so typography and layout remain independent application markup:

vue
<div class="flex items-center gap-3">
  <Radio
    id="region-lagos"
    v-model="region"
    name="region"
    value="lagos"
    class="size-5 text-emerald-700 focus-visible:outline-emerald-700"
  />
  <label for="region-lagos" class="text-sm text-gray-600">Lagos</label>
</div>

Keep the id and for association: it gives the Radio its accessible name and makes the text a larger click target. Styling the label or an entire selected card with peer-checked:* or has-[:checked]:* is optional caller markup.

For example:

vue
<!-- Larger operational control -->
<Radio class="size-5 text-emerald-700 focus-visible:outline-emerald-700" />

<!-- Entire label becomes the visible choice -->
<label class="has-checked:bg-gray-950 has-checked:text-white">
  <Radio class="sr-only" />
  Team plan
</label>

There is no Klean theme token or variant mapping between the application and its source.

Durable state

Radio preserves the browser's form and reset contract. The owning feature chooses durability:

  • framework state for a temporary form choice;
  • a URL parameter for a shareable filter;
  • the database for an account or team preference;
  • draft persistence only when losing an unfinished form would harm the user.

The primitive never guesses which policy applies and never writes to storage by itself.

  • Checkbox — zero, one, or many independent values.
  • Switch — an immediate boolean setting.
  • Select — one value from a longer fixed list.
  • Combobox — one searchable value.
  • Input — a free-form value rather than a fixed choice.

Complete framework source

Vue

Radio.vue
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const model = defineModel()
const attrs = useAttrs()
const element = ref()
let form

const forwardedAttrs = computed(() => {
  const {
    class: _class,
    type: _type,
    checked: _checked,
    'data-slot': _dataSlot,
    'data-state': _dataState,
    'data-disabled': _dataDisabled,
    'data-invalid': _dataInvalid,
    ...rest
  } = attrs
  return rest
})

const disabled = computed(
  () => attrs.disabled !== undefined && attrs.disabled !== false
)
const invalid = computed(
  () => attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true'
)

function inputValue() {
  if (!element.value) return undefined
  return Object.prototype.hasOwnProperty.call(element.value, '_value')
    ? element.value._value
    : element.value.value
}

const checked = computed(() => Object.is(model.value, attrs.value ?? 'on'))

function groupHasCheckedRadio() {
  if (!element.value) return false
  const root = element.value.form ?? element.value.getRootNode()
  const controls =
    element.value.form?.elements ??
    root?.querySelectorAll?.('input[type="radio"]') ??
    []

  return Array.from(controls).some(
    (control) =>
      control.type === 'radio' &&
      control.name === element.value.name &&
      control.form === element.value.form &&
      control.checked
  )
}

function resetModelFromElement() {
  if (element.value.checked) model.value = inputValue()
  else if (!groupHasCheckedRadio()) model.value = undefined
}

function handleReset() {
  queueMicrotask(resetModelFromElement)
}

onMounted(() => {
  element.value.defaultChecked = element.value.checked
  form = element.value.form
  form?.addEventListener('reset', handleReset)
})

onBeforeUnmount(() => {
  form?.removeEventListener('reset', handleReset)
})

defineExpose({
  element,
  focus: (options) => element.value?.focus(options)
})
</script>

<template>
  <input
    ref="element"
    v-model="model"
    v-bind="forwardedAttrs"
    type="radio"
    data-slot="radio"
    :data-state="checked ? 'checked' : 'unchecked'"
    :data-disabled="disabled ? '' : undefined"
    :data-invalid="invalid ? '' : undefined"
    :class="
      twMerge(
        [
          'size-4 shrink-0 cursor-pointer appearance-auto accent-current text-gray-950 outline-none',
          'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
          'disabled:cursor-not-allowed disabled:opacity-50',
          'aria-invalid:focus-visible:outline-red-600',
          'dark:text-white dark:focus-visible:outline-white dark:aria-invalid:focus-visible:outline-red-500'
        ],
        attrs.class
      )
    "
  />
</template>

React

Radio.jsx
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'

const BASE_CLASSES = [
  'size-4 shrink-0 cursor-pointer appearance-auto accent-current text-gray-950 outline-none',
  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
  'disabled:cursor-not-allowed disabled:opacity-50',
  'aria-invalid:focus-visible:outline-red-600',
  'dark:text-white dark:focus-visible:outline-white dark:aria-invalid:focus-visible:outline-red-500'
]

function assignRef(ref, value) {
  if (typeof ref === 'function') ref(value)
  else if (ref) ref.current = value
}

const Radio = forwardRef(function Radio(
  {
    checked,
    defaultChecked = false,
    disabled = false,
    value = 'on',
    'aria-invalid': ariaInvalid,
    className,
    onChange,
    'data-slot': _dataSlot,
    'data-state': _dataState,
    'data-disabled': _dataDisabled,
    'data-invalid': _dataInvalid,
    ...props
  },
  forwardedRef
) {
  const elementRef = useRef(null)
  const controlled = checked !== undefined
  const [localChecked, setLocalChecked] = useState(Boolean(defaultChecked))
  const resolvedChecked = controlled ? Boolean(checked) : localChecked
  const invalid = ariaInvalid === true || ariaInvalid === 'true'

  const setElement = useCallback(
    (node) => {
      elementRef.current = node
      assignRef(forwardedRef, node)
    },
    [forwardedRef]
  )

  useEffect(() => {
    const node = elementRef.current
    if (!node || controlled) return

    const root = node.form ?? node.getRootNode()

    function syncGroupState(event) {
      const target = event.target
      if (
        target?.type !== 'radio' ||
        target.name !== node.name ||
        target.form !== node.form
      ) {
        return
      }

      setLocalChecked(node.checked)
    }

    function handleReset() {
      queueMicrotask(() => setLocalChecked(node.checked))
    }

    root?.addEventListener('change', syncGroupState)
    node.form?.addEventListener('reset', handleReset)

    return () => {
      root?.removeEventListener('change', syncGroupState)
      node.form?.removeEventListener('reset', handleReset)
    }
  }, [controlled])

  function handleChange(event) {
    if (!controlled) setLocalChecked(event.target.checked)
    onChange?.(event)
  }

  return (
    <input
      {...props}
      ref={setElement}
      type="radio"
      checked={controlled ? Boolean(checked) : undefined}
      defaultChecked={controlled ? undefined : Boolean(defaultChecked)}
      disabled={disabled}
      value={value}
      aria-invalid={ariaInvalid}
      data-slot="radio"
      data-state={resolvedChecked ? 'checked' : 'unchecked'}
      data-disabled={disabled ? '' : undefined}
      data-invalid={invalid ? '' : undefined}
      className={twMerge(BASE_CLASSES, className)}
      onChange={handleChange}
    />
  )
})

export default Radio

Svelte

Radio.svelte
<script>
  import { twMerge } from "tailwind-merge";

  const BASE_CLASSES = [
    "size-4 shrink-0 cursor-pointer appearance-auto accent-current text-gray-950 outline-none",
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950",
    "disabled:cursor-not-allowed disabled:opacity-50",
    "aria-invalid:focus-visible:outline-red-600",
    "dark:text-white dark:focus-visible:outline-white dark:aria-invalid:focus-visible:outline-red-500",
  ];

  let {
    group = $bindable(),
    value = "on",
    disabled = false,
    class: className,
    type: _type,
    checked: _checked,
    "data-slot": _dataSlot,
    "data-state": _dataState,
    "data-disabled": _dataDisabled,
    "data-invalid": _dataInvalid,
    ...props
  } = $props();

  let element = $state();
  let initialChecked;
  let state = $derived(Object.is(group, value) ? "checked" : "unchecked");
  let invalid = $derived(
    props["aria-invalid"] === true || props["aria-invalid"] === "true",
  );

  function groupHasCheckedRadio(node) {
    const root = node.form ?? node.getRootNode();
    const controls =
      node.form?.elements ??
      root?.querySelectorAll?.('input[type="radio"]') ??
      [];

    return Array.from(controls).some(
      (control) =>
        control.type === "radio" &&
        control.name === node.name &&
        control.form === node.form &&
        control.checked,
    );
  }

  $effect(() => {
    const node = element;
    if (!node) return;

    if (initialChecked === undefined) {
      initialChecked = Object.is(group, value);
      node.defaultChecked = initialChecked;
    }

    function handleReset() {
      queueMicrotask(() => {
        if (node.checked) group = value;
        else if (!groupHasCheckedRadio(node)) group = undefined;
      });
    }

    node.form?.addEventListener("reset", handleReset);
    return () => node.form?.removeEventListener("reset", handleReset);
  });

  export function getElement() {
    return element;
  }

  export function focus(options) {
    element?.focus(options);
  }
</script>

<input
  {...props}
  bind:this={element}
  bind:group
  type="radio"
  {value}
  {disabled}
  data-slot="radio"
  data-state={state}
  data-disabled={disabled ? "" : undefined}
  data-invalid={invalid ? "" : undefined}
  class={twMerge(BASE_CLASSES, className)}
/>

All open source projects are released under the MIT License.