Forms
Radio
Option button for single-selection groups
Preview
size
labels
required
error
disabled
Code
import css from "./Radio.module.css";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface RadioProps {
size: Size | ResponsiveSize;
name: string;
value: string;
label?: string;
"aria-label"?: string;
checked: boolean;
required?: boolean;
error?: boolean;
disabled?: boolean;
onChange: (value: string) => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Radio({
size,
name,
value,
label,
"aria-label": ariaLabel,
checked,
required,
error,
disabled,
onChange,
}: RadioProps) {
const handleChange = () => {
onChange(value);
};
const Wrapper = label ? "label" : "span";
return (
<Wrapper
className={`
${css.radio}
${getSizeClasses(size)}
${checked ? css.checked : ""}
${error ? css.error : ""}
${disabled ? css.disabled : ""}
`}
>
<input
type="radio"
name={name}
value={value}
checked={checked}
required={required}
disabled={disabled}
aria-label={label ? undefined : ariaLabel}
aria-invalid={error}
onChange={handleChange}
/>
<span className={css.radio_circle}>
<span className={css.circle_dot}></span>
</span>
{label && (
<span className={css.radio_label}>
{label}
{required && <span aria-hidden="true">*</span>}
</span>
)}
</Wrapper>
);
}
.radio {
position: relative;
/* Keeps the stretched input's layer inside: loose, it covers a popover opened over it. */
isolation: isolate;
display: inline-flex;
align-items: center;
gap: var(--space-8);
/* WCAG 2.5.8: the root never measures under 24px, whatever box the step draws. */
min-width: 24px;
min-height: 24px;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
cursor: pointer;
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-text);
user-select: none;
/* The steps sit below the control ladder, which starts at 28. */
&.xs {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
&.xs_base {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_base {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_base {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_base {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_mobile {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_mobile {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_mobile {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_tablet {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_tablet {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_tablet {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_desktop {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_desktop {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_desktop {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_wide {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_wide {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_wide {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
}
/* Transparent and stretched over the root: every click lands on the native control. */
input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.radio_circle {
display: flex;
align-items: center;
justify-content: center;
width: var(--radio-size);
height: var(--radio-size);
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-full);
}
.circle_dot {
width: var(--radio-dot);
height: var(--radio-dot);
background: var(--color-text);
border-radius: var(--radius-full);
opacity: 0;
transform: scale(0.5);
transition:
opacity 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: opacity 0.2s ease;
}
}
&:not(:has(.radio_label)) {
justify-content: center;
}
&:has(.radio_label):has(input:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:not(:has(.radio_label)):has(input:focus-visible) .radio_circle {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.checked {
.radio_circle {
border-color: var(--color-contour-accent);
}
.circle_dot {
background: var(--color-accent);
opacity: 1;
transform: scale(1);
}
}
&.error {
.radio_circle {
border-color: var(--color-error);
}
.circle_dot {
background: var(--color-error);
}
.radio_label {
color: var(--color-error);
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
<script setup lang="ts">
import { computed } from "vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface RadioProps {
size: Size | ResponsiveSize;
name: string;
value: string;
label?: string;
ariaLabel?: string;
required?: boolean;
error?: boolean;
disabled?: boolean;
}
const { size, value } = defineProps<RadioProps>();
// In Vue the group model is the v-model and checked derives from it.
const model = defineModel<string>({ required: true });
const checked = computed(() => model.value === value);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
</script>
<template>
<component
:is="label ? 'label' : 'span'"
class="radio"
:class="[sizeClasses, { checked, error, disabled }]"
>
<input
v-model="model"
type="radio"
:name="name"
:value="value"
:required="required"
:disabled="disabled"
:aria-label="label ? undefined : ariaLabel"
:aria-invalid="error"
/>
<span class="radio_circle">
<span class="circle_dot"></span>
</span>
<span v-if="label" class="radio_label">
{{ label }}<span v-if="required" aria-hidden="true">*</span>
</span>
</component>
</template>
<style scoped>
.radio {
position: relative;
/* Keeps the stretched input's layer inside: loose, it covers a popover opened over it. */
isolation: isolate;
display: inline-flex;
align-items: center;
gap: var(--space-8);
/* WCAG 2.5.8: the root never measures under 24px, whatever box the step draws. */
min-width: 24px;
min-height: 24px;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
cursor: pointer;
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-text);
user-select: none;
/* The steps sit below the control ladder, which starts at 28. */
&.xs {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
&.xs_base {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_base {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_base {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_base {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_mobile {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_mobile {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_mobile {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_tablet {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_tablet {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_tablet {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_desktop {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_desktop {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_desktop {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--radio-size: 16px;
--radio-dot: 6px;
font-size: var(--text-14);
}
&.sm_wide {
--radio-size: 18px;
--radio-dot: 7px;
font-size: var(--text-14);
}
&.md_wide {
--radio-size: 20px;
--radio-dot: 8px;
font-size: var(--text-16);
}
&.lg_wide {
--radio-size: 22px;
--radio-dot: 9px;
font-size: var(--text-16);
}
}
/* Transparent and stretched over the root: every click lands on the native control. */
input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.radio_circle {
display: flex;
align-items: center;
justify-content: center;
width: var(--radio-size);
height: var(--radio-size);
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-full);
}
.circle_dot {
width: var(--radio-dot);
height: var(--radio-dot);
background: var(--color-text);
border-radius: var(--radius-full);
opacity: 0;
transform: scale(0.5);
transition:
opacity 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: opacity 0.2s ease;
}
}
&:not(:has(.radio_label)) {
justify-content: center;
}
&:has(.radio_label):has(input:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:not(:has(.radio_label)):has(input:focus-visible) .radio_circle {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.checked {
.radio_circle {
border-color: var(--color-contour-accent);
}
.circle_dot {
background: var(--color-accent);
opacity: 1;
transform: scale(1);
}
}
&.error {
.radio_circle {
border-color: var(--color-error);
}
.circle_dot {
background: var(--color-error);
}
.radio_label {
color: var(--color-error);
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Control size (circle, dot and label). Fixed or responsive. |
| name | string | required | Group name. Radios sharing a name form one group. |
| value | string | required | Value identifying this option within the group. |
| label | string | — | Text next to the radio. Without a label, only the circle remains. |
| aria-label | string | — | Name of the control for screen readers. Required when there is no label; ignored when there is one. |
| required | boolean | — | Marks the group as required (adds * to the label and the native required). |
| error | boolean | — | Visual error state (red border, aria-invalid). |
| disabled | boolean | — | Disables the option (not interactive, reduced opacity). |
| checked | boolean | required | Whether this is the selected option of the group (controlled). |
| onChange | (value: string) => void | required | Callback with the value of the chosen option. |