<script setup lang="ts">
import { ref } from 'vue'
import { RadioGroup } from '@hina-ui/vue'
const status = ref('doing')
const options = [
{ value: 'wish', label: 'Want to read' },
{ value: 'doing', label: 'Reading' },
{ value: 'done', label: 'Finished' },
]
</script>
<template>
<RadioGroup v-model="status" :options="options" aria-label="Library status" />
</template>
Usage
import { RadioGroup } from '@hina-ui/vue'
The group renders one radio per entry in options, and v-model binds the chosen value; the option type is the same as Select’s. Attributes it does not declare land on the root, so name the group with aria-label or aria-labelledby. Radios only come as a group; there is no standalone Radio.
Value: system
<script setup lang="ts">
import { ref } from 'vue'
import { RadioGroup, Stack, Text } from '@hina-ui/vue'
const theme = ref('system')
const options = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'Follow system' },
]
</script>
<template>
<Stack gap="sm">
<RadioGroup v-model="theme" :options="options" aria-label="Theme" />
<Text size="sm" tone="muted">Value: {{ theme }}</Text>
</Stack>
</template>
Examples
Horizontal
With orientation="horizontal" the options run in a row and wrap when they run out of room.
<script setup lang="ts">
import { ref } from 'vue'
import { RadioGroup } from '@hina-ui/vue'
const sort = ref('updated')
const options = [
{ value: 'updated', label: 'Recently updated' },
{ value: 'rating', label: 'Rating' },
{ value: 'title', label: 'Title' },
]
</script>
<template>
<RadioGroup v-model="sort" :options="options" orientation="horizontal" aria-label="Sort by" />
</template>
Description
An option’s description shows under its label.
<script setup lang="ts">
import { ref } from 'vue'
import { RadioGroup } from '@hina-ui/vue'
const visibility = ref('friends')
const options = [
{ value: 'public', label: 'Public', description: 'Anyone can see this post' },
{
value: 'friends',
label: 'Friends only',
description: 'Only people you follow back can see it',
},
{ value: 'private', label: 'Only me', description: 'Only you can see it' },
]
</script>
<template>
<RadioGroup v-model="visibility" :options="options" aria-label="Visibility" />
</template>
Settings rows
Use control-placement="end" to place the control after the text and block to fill the available width. The description stays below the label. start and end follow the text direction. With block, vertical items fill each row; horizontal items share the row equally. orientation still controls the arrangement of options.
For RTL, pass dir="rtl" to the group or configure Reka’s direction provider.
<script setup lang="ts">
import { ref } from 'vue'
import { RadioGroup } from '@hina-ui/vue'
const visibility = ref('friends')
const options = [
{ value: 'public', label: 'Public', description: 'Anyone can see this post' },
{
value: 'friends',
label: 'Friends only',
description: 'Only people you follow back can see it',
},
{ value: 'private', label: 'Only me', description: 'Only you can see it' },
]
</script>
<template>
<RadioGroup
v-model="visibility"
control-placement="end"
block
:options="options"
aria-label="Visibility"
/>
</template>
Sizes
size is passed to every radio; the circle and the text follow it.
<script setup lang="ts">
import { RadioGroup, Inline } from '@hina-ui/vue'
const options = [
{ value: 'wish', label: 'Want to read' },
{ value: 'doing', label: 'Reading' },
]
</script>
<template>
<Inline gap="lg" align="start">
<RadioGroup size="sm" :options="options" model-value="wish" aria-label="Small" />
<RadioGroup size="md" :options="options" model-value="wish" aria-label="Medium" />
<RadioGroup size="lg" :options="options" model-value="wish" aria-label="Large" />
</Inline>
</template>
States
disabled on an option disables that option only; on the group it disables them all. invalid reaches every circle.
<script setup lang="ts">
import { RadioGroup, Inline } from '@hina-ui/vue'
const options = [
{ value: 'wish', label: 'Want to read' },
{ value: 'doing', label: 'Reading' },
{ value: 'done', label: 'Finished', disabled: true },
]
</script>
<template>
<Inline gap="lg" align="start">
<RadioGroup :options="options" model-value="wish" aria-label="With a disabled option" />
<RadioGroup disabled :options="options" model-value="wish" aria-label="Disabled group" />
<RadioGroup invalid :options="options" aria-label="Invalid" />
</Inline>
</template>
Custom content
The option slot customises each label.
The complete option type is inferred from options. Slot parameters preserve additional fields and their types; v-model still binds to value. See Select for the type definitions.
<script setup lang="ts">
import { ref } from 'vue'
import { Monitor, Moon, Sun } from '@lucide/vue'
import { RadioGroup, Inline } from '@hina-ui/vue'
const icons = { light: Sun, dark: Moon, system: Monitor }
const theme = ref('light')
const options = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'Follow system' },
]
</script>
<template>
<RadioGroup v-model="theme" :options="options" aria-label="Theme">
<template #option="{ option }">
<Inline as="span" gap="xs" align="center">
<component :is="icons[option.value as keyof typeof icons]" class="text-muted size-4" />
{{ option.label }}
</Inline>
</template>
</RadioGroup>
</template>
In a form
Inside a FormField the label is linked to the whole group through aria-labelledby, and the error message is rendered by the field; validation rules and submission belong to the Form.
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as v from 'valibot'
import { Button, Form, FormField, RadioGroup, Text } from '@hina-ui/vue'
const options = [
{ label: 'Public', value: 'public', description: 'Visible to everyone' },
{ label: 'Followers', value: 'followers', description: 'Visible to people who follow you' },
{ label: 'Private', value: 'private', description: 'Visible only to you' },
]
const schema = v.object({
visibility: v.string('Pick who can see it'),
})
const values = reactive({ visibility: null as string | number | null })
const saved = ref('')
async function save(data: unknown) {
await new Promise(resolve => setTimeout(resolve, 600))
saved.value = JSON.stringify(data)
}
</script>
<template>
<Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
<FormField name="visibility" label="Visibility" required>
<RadioGroup v-model="values.visibility" :options="options" />
</FormField>
<Button type="submit" :loading="submitting" class="self-start">Publish</Button>
<Text v-if="saved" tone="muted" size="sm">Published: {{ saved }}</Text>
</Form>
</template>
Behaviour
- Clicking the label or the circle chooses that option; the chosen one cannot be clicked back to unchosen.
- The whole group is one Tab stop, landing on the chosen option; the arrow keys move focus and choose at the same time, skip disabled options and wrap around at the ends. This matches native radios.
Accessibility
- The root is
role="radiogroup", named witharia-labeloraria-labelledby; each item is a button withrole="radio"andaria-checked, and the wrappinglabelsupplies its name. invalidsetsaria-invalidon every radio.
API
Props
T extends SelectOption is inferred from options and defaults to SelectOption.
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | number | null | — | The chosen value |
options | T[] | — | The items, see Select |
orientation | 'vertical' | 'horizontal' | 'vertical' | Layout direction |
size | 'sm' | 'md' | 'lg' | 'md' | Size of every radio |
controlPlacement | 'start' | 'end' | 'start' | Position of the control relative to its text |
block | boolean | false | Fill the group; horizontal items share the width |
disabled | boolean | false | Whether the whole group is disabled |
invalid | boolean | false | Whether the group failed validation |
class | string | — | Classes appended to the root element |
Slots
| Slot | Payload | Description |
|---|---|---|
option | { option: T } | Label of each item |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: string | number | The chosen value changed |