Skip to content

Radio Buttons

A radio group is a set of checkable buttons, known as radio buttons, where no more than one of the buttons can be checked at a time. Some implementations may initialize the set with all buttons in the unchecked state to force the user to check one of the buttons before moving past a certain point in the workflow.

Radios in HTML do not have a “group” concept, but they get grouped implicitly by the “name” attribute. This isn’t the case in Vue and most UI libraries, as they are grouped by the model name they mutate.

Formwerk follows the “group” concept to provide a consistent API for radio fields regardless of whether they are bound to the same model or if they have the same name or not.

This means radios are a compound field, meaning they require more than one composable to work properly, and by extension, you need to build more than one component to make them work.

For radios, you will use the useRadioGroup and useRadio composables to build radio components.

Features

You can build radio components using either the native HTML input[type="radio"] elements or other HTML elements. We provide the behavior, state, and accessibility implementation for both cases with the same API and features.

The following features are implemented:

  • Support for either input[type="radio"] or custom HTML elements as a base element for the radio component.
  • Labeling, descriptions, and error message displays are automatically linked to input and label elements with aria-* attributes.
  • Form management, data collection, and validation with native HTML5 validation or Standard Schema validation.
  • Support for orientation with horizontal and vertical values.
  • v-model support for radio groups.
  • Supported Keyboard features:
KeyDescription
ArrowDown
Focuses the next radio item in the group.
ArrowRight
Focuses the next radio item in the group. In RTL, focuses the previous item.
Arrow Left
Focuses the previous radio item in the group.
Arrow Up
Focuses the previous radio item in the group. In RTL, focuses the next item.
Tab
Focuses the selected item in the group. If none selected, focuses the first one.
Space
Selects the focused radio item.

Anatomy

Choose Drink
Group Label
Tea
Coffee
Input
Water
Radio Label
Radio Group

Building a Radio Group Component

The useRadioGroup provides the behavior, state, and accessibility implementation for group components.

Unlike checkboxes, radio components MUST be grouped by a radio group component. This is why we will start by building a RadioGroup component as a prerequisite. We will be using this component in the following examples throughout this page.

<script setup lang="ts">
import { type RadioGroupProps, useRadioGroup } from '@formwerk/core';
const props = defineProps<RadioGroupProps>();
const {
groupProps,
labelProps,
descriptionProps,
errorMessageProps,
errorMessage,
isTouched,
} = useRadioGroup(props);
</script>
<template>
<div
v-bind="groupProps"
class="radio-group"
:class="{ 'is-touched': isTouched }"
>
<div v-bind="labelProps" class="group-label">{{ label }}</div>
<div class="radios-container">
<slot />
</div>
<div v-if="errorMessage" v-bind="errorMessageProps" class="error">
{{ errorMessage }}
</div>
<div v-else-if="description" v-bind="descriptionProps" class="hint">
{{ description }}
</div>
</div>
</template>
68 collapsed lines
<style scoped>
.radio-group {
--color-text: #333;
--color-hint: #666;
--color-border: #d4d4d8;
--color-focus: #007bff;
--color-error: #f00;
--color-valid: #059669;
--color-hover: #eee;
display: flex;
flex-direction: column;
.hint,
.error {
margin-top: 0.25rem;
}
.error {
color: var(--color-error);
display: none;
font-size: 13px;
}
.hint {
color: var(--color-hint);
font-size: 13px;
opacity: 0;
transition: opacity 0.3s ease;
}
.radios-container {
margin-top: 0.25rem;
display: flex;
gap: 0.5rem;
}
.group-label {
color: var(--color-text);
display: block;
margin-bottom: 0.25rem;
font-size: 14px;
font-weight: 500;
}
&:has(:focus) {
.hint {
opacity: 1;
}
}
&.is-touched[aria-invalid='true'] {
.error {
display: block;
}
.hint {
display: none;
}
}
&[aria-orientation='vertical'] {
.radios-container {
flex-direction: column;
}
}
}
</style>

Building a Radio Component

With the Radio Group component built, we can now build a RadioItem component. You will be using the useRadio composable to build it.

You can use either the native HTML input[type="radio"] element or custom HTML elements. It doesn’t matter which one you choose; both have the same exact API, and Formwerk does the work needed for each case behind the scenes.

import { type RadioProps, useRadio } from '@formwerk/core';
const props = defineProps<RadioProps>();
const { labelProps, inputProps } = useRadio(props);

The most important part is to bind the inputProps object to the base element, the element that you consider to be the radio button. We also provide the RadioProps type for you to use as your component props. You are not required to use it, but it is recommended to make use of the full feature set of the useRadio composable and, by extension, your component.

With the basics out of the way, let’s build a radio component with two common variations.

With input element as a base

You can use the useRadio composable to build a radio component with the input element as a base.

<script setup lang="ts">
import RadioItem from './RadioItem.vue';
import RadioGroup from './RadioGroup.vue';
</script>
<template>
<RadioGroup label="Radio Group">
<RadioItem label="Radio 1" value="1" />
<RadioItem label="Radio 2" value="2" />
<RadioItem label="Radio 3" value="3" />
</RadioGroup>
</template>
<script setup lang="ts">
import { type RadioProps, useRadio } from '@formwerk/core';
const props = defineProps<RadioProps>();
const { labelProps, inputProps } = useRadio(props);
</script>
<template>
<label v-bind="labelProps" class="radio-item">
<input v-bind="inputProps" class="sr-only" />
<div class="radio-circle">
<div class="radio-circle-inner"></div>
</div>
{{ label }}
</label>
</template>
65 collapsed lines
<style scoped>
.radio-item {
--color-primary: #10b981;
--color-text: #333;
--color-hint: #666;
--color-border: #d4d4d8;
--color-focus: var(--color-primary);
--color-error: #f00;
--color-hover: #eee;
display: inline-flex;
width: max-content;
gap: 4px;
align-items: center;
user-select: none;
font-size: 13px;
font-weight: 500;
.radio-circle {
width: 1rem;
height: 1rem;
border-radius: 9999999px;
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
}
.radio-circle-inner {
width: 70%;
height: 70%;
border-radius: 9999999px;
}
&:has(:focus) {
.radio-circle {
border: 1px solid var(--color-focus);
outline: 1px solid var(--color-focus);
}
}
&:has(:checked) {
.radio-circle-inner {
background-color: var(--color-focus);
}
}
&:has(:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
}
/** This is a common utility CSS class, you can find it in your CSS framework of choice */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>

The style-ability of the last example is limited to the styling capabilities of the native input element. To work around that, check the styling section.

With custom HTML element as a base

For unlimited styling freedom, you don’t have to use the input element. With the same API, you can use custom HTML elements as a binding target for the inputProps object.

In the following example, we are using a span element as a base element for the radio. Try keyboard navigation, clicking, focusing, and other interactions to see how it behaves.

<script setup lang="ts">
import RadioItem from './RadioItem.vue';
import RadioGroup from './RadioGroup.vue';
</script>
<template>
<RadioGroup label="Radio Group">
<RadioItem label="Radio 1" value="1" />
<RadioItem label="Radio 2" value="2" />
<RadioItem label="Radio 3" value="3" />
</RadioGroup>
</template>
<script setup lang="ts">
import { type RadioProps, useRadio } from '@formwerk/core';
const props = defineProps<RadioProps>();
const { labelProps, inputProps } = useRadio(props);
</script>
<template>
<div class="radio-item" v-bind="inputProps">
<div class="radio-circle">
<div class="radio-circle-inner"></div>
</div>
<span v-bind="labelProps">{{ label }}</span>
</div>
</template>
60 collapsed lines
<style scoped>
.radio-item {
--color-primary: #10b981;
--color-text: #333;
--color-hint: #666;
--color-border: #d4d4d8;
--color-focus: var(--color-primary);
--color-error: #f00;
--color-hover: #eee;
display: inline-flex;
width: max-content;
gap: 4px;
align-items: center;
user-select: none;
font-size: 13px;
font-weight: 500;
cursor: pointer;
.radio-circle {
width: 1rem;
height: 1rem;
border-radius: 9999999px;
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
}
.radio-circle-inner {
width: 70%;
height: 70%;
border-radius: 9999999px;
}
&:focus {
.radio-circle {
border: 1px solid var(--color-focus);
outline: 1px solid var(--color-focus);
}
}
&[aria-checked='true'] {
.radio-circle-inner {
background-color: var(--color-focus);
}
}
&[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
&[aria-readonly='true'] {
.radio-circle {
opacity: 0.5;
}
}
}
</style>

Validation

Radio components support validation with native HTML5 constraints or Standard Schema validation. However, the useRadioGroup is the one that accepts validation props.

HTML Constraint Validation

The following properties are supported on useRadioGroup and useRadio that use the input element as a base. Custom elements do not support these properties.

NameTypeDescription
requiredbooleanWhether the number field is required.
<script setup lang="ts">
import Radio from './Radio.vue';
import RadioGroup from './RadioGroup.vue';
</script>
<template>
<RadioGroup label="Radio Group" required>
<Radio label="Radio 1" value="1" />
<Radio label="Radio 2" value="2" />
<Radio label="Radio 3" value="3" />
</RadioGroup>
</template>

Standard Schema

useRadioGroup also supports Standard Schema validation through the schema prop. This includes multiple providers like Zod, Valibot, Arktype, and more.

Here is an example of using zod as a Standard Schema to validate the radio group. We will be using the radio item component from the previous examples.

<script setup lang="ts">
import RadioItem from './RadioItem.vue';
import RadioGroup from './RadioGroup.vue';
import { z } from 'zod';
const schema = z
.string()
.min(1, 'Please select a drink')
.endsWith('☕️', 'WRONG ANSWER!');
</script>
<template>
<RadioGroup label="Select a drink" :schema="schema">
<RadioItem label="Tea" value="🍵" />
<RadioItem label="Coffee" value="☕️" />
<RadioItem label="Milk" value="🥛" />
</RadioGroup>
</template>

Usage

Disabled

You can disable individual radio items or the whole group with the disabled prop on either. Disabled radio items are not focusable. Disabled groups are not submitted and are not validated.

We made use of the styled radio component that we created above to make it clearer that the radio items are disabled.

<script setup lang="ts">
import RadioItem from './RadioItem.vue';
import RadioGroup from './RadioGroup.vue';
</script>
<template>
<RadioGroup label="Radio Group">
<RadioItem label="Radio 1" value="1" />
<RadioItem label="Radio 2" value="2" />
<RadioItem label="Radio 3" value="3" disabled />
<RadioItem label="Radio 4" value="4" />
</RadioGroup>
<RadioGroup label="Disabled Group" disabled>
<RadioItem label="Radio 1" value="1" />
<RadioItem label="Radio 2" value="2" />
<RadioItem label="Radio 3" value="3" />
</RadioGroup>
</template>

If you need to prevent the user from interacting with the group while still allowing it to submit, consider using readonly instead.

Readonly

Only available on the group, the readonly prop prevents the user from interacting with the group while still allowing it to submit and be validated.

<script setup lang="ts">
import { ref } from 'vue';
import RadioItem from './RadioItem.vue';
import RadioGroup from './RadioGroup.vue';
const value = ref('☕️');
</script>
<template>
<RadioGroup
label="Select a drink"
v-model="value"
description="Can't change this"
readonly
>
<RadioItem label="Tea 🍵" value="🍵" />
<RadioItem label="Coffee ☕️" value="☕️" />
<RadioItem label="Milk 🥛" value="🥛" />
</RadioGroup>
</template>

Orientation

Radio groups accept an orientation prop that can be set to horizontal or vertical. The orientation does not affect the focus order, but you can use it to layout the radio items in a row or column with CSS.

There is no default value assumed for the orientation, but if it is provided, the group element will have an aria-orientation attribute set to the value of the prop. So you can use that to style it.

<script setup lang="ts">
import RadioItem from './RadioItem.vue';
import RadioGroup from './RadioGroup.vue';
</script>
<template>
<RadioGroup label="Radio Group" orientation="vertical">
<RadioItem label="Radio 1" value="1" />
<RadioItem label="Radio 2" value="2" />
<RadioItem label="Radio 3" value="3" />
</RadioGroup>
</template>

RTL

The radio group accepts a dir prop that can be set to ltr or rtl. Unlike the orientation, the dir prop affects the focus order of the radio items as the Left and Right arrow keys will navigate the items in the opposite direction.

<script setup lang="ts">
import RadioItem from './RadioItem.vue';
import RadioGroup from './RadioGroup.vue';
</script>
<template>
<RadioGroup label="من اليمين لليسار" dir="rtl">
<RadioItem label="الحقل الأول" value="1" />
<RadioItem label="الحقل الثاني" value="2" />
<RadioItem label="الحقل الثالث" value="3" />
</RadioGroup>
</template>

API

Most of the values expressed below are wrapped in Ref as they are reactive values.

Radio Group

Props

These are the properties that can be passed to the useRadioGroup composable.

NameTypeDescription
descriptionThe description text for the radio group.
dirThe text direction of the radio group (ltr or rtl).
disabledWhether the radio group is disabled.
disableHtmlValidationWhether to disable HTML5 form validation.
labelThe label text for the radio group.
modelValueThe v-model value of the radio group.
nameThe name attribute for the radio group.
orientationThe orientation of the radio group (horizontal or vertical).
readonlyWhether the radio group is readonly.
requiredWhether the radio group is required.
schemaSchema for radio group validation.

Returns

These are the properties in the object returned by the useRadioGroup composable.

NameTypeDescription
descriptionPropsProps for the description element.
displayErrorDisplay the error message for the field.
errorMessageThe error message for the field.
errorMessageProps
Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }>
Props for the error message element.
errorsThe errors for the field.
fieldValueThe value of the field.
groupPropsProps for the group element.
isDirtyWhether the field is dirty.
isDisabledWhether the field is disabled.
isTouchedWhether the field is touched.
isValidWhether the field is valid.
labelPropsProps for the label element.
setErrors
(messages: Arrayable<string>) => void
Sets the errors for the field.
setTouchedSets the touched state for the field.
setValueSets the value for the field.
validityDetailsValidity details for the radio group.

Radio

Props

These are the properties that can be passed to the useRadio composable.

NameTypeDescription
disabledWhether the radio button is disabled.
labelThe label text for the radio button.
valueThe value associated with this radio button.

Returns

These are the properties in the object returned by the useRadio composable.

NameTypeDescription
inputElReference to the input element.
inputProps
Ref<RadioDomInputProps | RadioDomProps>
Props for the input element.
isCheckedWhether the radio is checked.
isDisabledWhether the radio is disabled.
labelPropsProps for the label element.