RadioGroup

Picks exactly one option from a set.

<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>
vue

Usage

import { RadioGroup } from '@hina-ui/vue'
ts

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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 with aria-label or aria-labelledby; each item is a button with role="radio" and aria-checked, and the wrapping label supplies its name.
  • invalid sets aria-invalid on 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