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.
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.
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
<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
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
<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-modelshared by each Radio. - React: native
checked,defaultChecked,value, andonChange. - Svelte: one
bind:groupshared 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:
<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
nameandvalue. requiredmakes the group participate in native constraint validation.disabledprevents 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:
<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:
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
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Current value | v-model | checked, onChange | bind:group |
| Initial value | initial model | defaultChecked | initial group |
| Choice | value | value | value |
| Group | shared native name | shared native name | shared native name |
| Form | native input attributes | native input props | native attributes |
| Styling | class | className | class |
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:
<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:
<!-- 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.
Related components
- 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
<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
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
<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)}
/>