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
andvertical
values. v-model
support for radio groups.- Supported Keyboard features:
Key | Description |
---|---|
↓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
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 { 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 { 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.
Name | Type | Description |
---|---|---|
required | boolean | Whether 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.
Name | Type | Description |
---|---|---|
description | The description text for the radio group. | |
dir | The text direction of the radio group (ltr or rtl). | |
disabled | Whether the radio group is disabled. | |
disableHtmlValidation | Whether to disable HTML5 form validation. | |
label | The label text for the radio group. | |
modelValue | The v-model value of the radio group. | |
name | The name attribute for the radio group. | |
orientation | The orientation of the radio group (horizontal or vertical). | |
readonly | Whether the radio group is readonly. | |
required | Whether the radio group is required. | |
schema | Schema for radio group validation. |
Returns
These are the properties in the object returned by the useRadioGroup
composable.
Name | Type | Description |
---|---|---|
descriptionProps | Props for the description element. | |
displayError | Display the error message for the field. | |
errorMessage | The error message for the field. | |
errorMessageProps | Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }> | Props for the error message element. |
errors | The errors for the field. | |
fieldValue | The value of the field. | |
groupProps | Props for the group element. | |
isDirty | Whether the field is dirty. | |
isDisabled | Whether the field is disabled. | |
isTouched | Whether the field is touched. | |
isValid | Whether the field is valid. | |
labelProps | Props for the label element. | |
setErrors | (messages: Arrayable<string>) => void | Sets the errors for the field. |
setTouched | Sets the touched state for the field. | |
setValue | Sets the value for the field. | |
submitErrorMessage | The error message for the field from the last submit attempt. | |
submitErrors | The errors for the field from the last submit attempt. | |
validityDetails | Validity details for the radio group. |
Radio
Props
These are the properties that can be passed to the useRadio
composable.
Name | Type | Description |
---|---|---|
disabled | Whether the radio button is disabled. | |
label | The label text for the radio button. | |
value | The value associated with this radio button. |
Returns
These are the properties in the object returned by the useRadio
composable.
Name | Type | Description |
---|---|---|
inputEl | Reference to the input element. | |
inputProps | Ref<RadioDomInputProps | RadioDomProps> | Props for the input element. |
isChecked | Whether the radio is checked. | |
isDisabled | Whether the radio is disabled. | |
labelProps | Props for the label element. |