SegmentedControl

Switches among a few peer options.

<script setup lang="ts">
  import { ref } from 'vue'
  import { SegmentedControl } from '@hina-ui/vue'

  const status = ref('all')
  const options = [
    { value: 'all', label: 'All' },
    { value: 'ongoing', label: 'Ongoing' },
    { value: 'done', label: 'Completed' },
  ]
</script>

<template>
  <SegmentedControl v-model="status" :options="options" aria-label="Status" />
</template>
vue

Usage

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

A segmented control lays a few peer options in a row; exactly one is selected at any time, and a sliding thumb marks it. options uses the types defined by Select, v-model binds the selected value, and with no bound value the first enabled option is selected. Attributes it does not declare land on the role="group" element, so name the group with aria-label or aria-labelledby; class is appended to the outer scroll container.

Sorted by: latest

<script setup lang="ts">
  import { ref } from 'vue'
  import { SegmentedControl, Stack, Text } from '@hina-ui/vue'

  const sort = ref('latest')
  const options = [
    { value: 'latest', label: 'Latest' },
    { value: 'popular', label: 'Popular' },
    { value: 'rating', label: 'Rating' },
  ]
</script>

<template>
  <Stack gap="sm" align="start">
    <SegmentedControl v-model="sort" :options="options" aria-label="Sort by" />
    <Text tone="muted" size="sm">Sorted by: {{ sort }}</Text>
  </Stack>
</template>
vue

It expresses the same choice as RadioGroup; the difference is the occasion. Use a segmented control for up to five short options that take effect immediately; use a radio group when the options need descriptions or the choice is submitted with a form. Unlike Tabs, it changes a value rather than which content is shown.

Examples

Custom content

The #option slot replaces the content of every item. With icons only, each item is still named by its label, so screen readers read it as usual.

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 { LayoutGrid, List, Rows3 } from '@lucide/vue'
  import { SegmentedControl } from '@hina-ui/vue'

  const icons = { grid: LayoutGrid, list: List, rows: Rows3 }
  const view = ref('grid')
  const options = [
    { value: 'grid', label: 'Grid' },
    { value: 'list', label: 'List' },
    { value: 'rows', label: 'Details' },
  ]
</script>

<template>
  <SegmentedControl v-model="view" :options="options" aria-label="View">
    <template #option="{ option }">
      <component :is="icons[option.value as keyof typeof icons]" />
    </template>
  </SegmentedControl>
</template>
vue

Sizes

size has three steps, sm, md and lg. The overall height equals an input of the same step, so it sits in one row with inputs and buttons.

<script setup lang="ts">
  import { SegmentedControl, Stack } from '@hina-ui/vue'

  const options = [
    { value: 'day', label: 'Day' },
    { value: 'week', label: 'Week' },
    { value: 'month', label: 'Month' },
  ]
</script>

<template>
  <Stack gap="sm" align="start">
    <SegmentedControl size="sm" :options="options" model-value="day" aria-label="Small" />
    <SegmentedControl size="md" :options="options" model-value="day" aria-label="Medium" />
    <SegmentedControl size="lg" :options="options" model-value="day" aria-label="Large" />
  </Stack>
</template>
vue

Block

block stretches the control to the width of its parent, with items of equal width.

<script setup lang="ts">
  import { ref } from 'vue'
  import { SegmentedControl } from '@hina-ui/vue'

  const range = ref('week')
  const options = [
    { value: 'day', label: 'Today' },
    { value: 'week', label: 'This week' },
    { value: 'month', label: 'This month' },
    { value: 'all', label: 'All time' },
  ]
</script>

<template>
  <SegmentedControl v-model="range" :options="options" block aria-label="Range" class="max-w-md" />
</template>
vue

When there are too many options

Once the options overflow their container the row scrolls sideways without showing a scrollbar: edge shadows indicate the scroll position, the mouse wheel scrolls the row directly, and an item scrolls into view when the arrow keys move focus to it. With block, items that do not fit keep their content width. A vertical control scrolls up and down instead and stays within the height of its parent.

<script setup lang="ts">
  import { ref } from 'vue'
  import { SegmentedControl } from '@hina-ui/vue'

  const sort = ref('relevance')
  const options = [
    { value: 'relevance', label: 'Relevance' },
    { value: 'updated', label: 'Recently updated' },
    { value: 'favorites', label: 'Most favorited' },
    { value: 'comments', label: 'Most comments' },
    { value: 'words', label: 'Longest' },
    { value: 'rating', label: 'Top rated' },
  ]
</script>

<template>
  <SegmentedControl v-model="sort" :options="options" class="max-w-xs" aria-label="Sort by" />
</template>
vue

Vertical

orientation="vertical" stacks the items; the thumb moves up and down.

<script setup lang="ts">
  import { ref } from 'vue'
  import { SegmentedControl } from '@hina-ui/vue'

  const align = ref('left')
  const options = [
    { value: 'left', label: 'Align left' },
    { value: 'center', label: 'Center' },
    { value: 'right', label: 'Align right' },
  ]
</script>

<template>
  <SegmentedControl
    v-model="align"
    :options="options"
    orientation="vertical"
    aria-label="Alignment"
  />
</template>
vue

States

disabled disables the whole group. A disabled option disables only that item, and keyboard navigation skips it.

<script setup lang="ts">
  import { SegmentedControl, Stack } from '@hina-ui/vue'

  const options = [
    { value: 'all', label: 'All' },
    { value: 'ongoing', label: 'Ongoing' },
    { value: 'done', label: 'Completed' },
  ]
  const partial = [
    { value: 'all', label: 'All' },
    { value: 'ongoing', label: 'Ongoing' },
    { value: 'done', label: 'Completed', disabled: true },
  ]
</script>

<template>
  <Stack gap="sm" align="start">
    <SegmentedControl :options="options" model-value="all" disabled aria-label="Group disabled" />
    <SegmentedControl :options="partial" model-value="all" aria-label="One item disabled" />
  </Stack>
</template>
vue

In a form

Inside a FormField the label is linked to the whole group through aria-labelledby; validation rules and submission belong to the Form. A segmented control always has a value, and that value often decides whether another field is required; such a rule goes on the object and names the field the error lands on.

mm
dd
yyyy
<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, DateField, Form, FormField, SegmentedControl, Text } from '@hina-ui/vue'

  const modes = [
    { label: 'Publish now', value: 'now' },
    { label: 'Schedule', value: 'scheduled' },
  ]

  const schema = v.pipe(
    v.object({
      mode: v.picklist(['now', 'scheduled'], 'Pick how to publish'),
      publishAt: v.nullable(v.string()),
    }),
    v.forward(
      v.check(input => input.mode !== 'scheduled' || !!input.publishAt, 'Pick a publish date'),
      ['publishAt'],
    ),
  )

  const values = reactive({
    mode: 'now' as string | number,
    publishAt: null as string | 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="mode" label="Publishing">
      <SegmentedControl v-model="values.mode" :options="modes" />
    </FormField>
    <FormField name="publishAt" label="Publish date" :disabled="values.mode !== 'scheduled'">
      <DateField v-model="values.publishAt" />
    </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

Behavior

  • Clicking an item selects it and the thumb slides to it; clicking the selected item again does not deselect it.
  • Tab lands on the selected item. Arrow keys move focus between items, Space or Enter selects the focused one, and focus wraps at both ends.
  • Hover and press ink land on the item; on the selected item the ink lands on the thumb.

Accessibility

  • The group is a role="group" element; every item is a button with aria-pressed.
  • Name the group with aria-label or aria-labelledby. With the #option slot, every item is named by its label.

API

Props

T extends SelectOption is inferred from options and defaults to SelectOption.

Prop
Type
Default
Description
modelValue
string | number
value of the first enabled
The selected value
options
T[]
—
The items, see Select
size
'sm' | 'md' | 'lg'
'md'
Size
orientation
'horizontal' | 'vertical'
'horizontal'
Layout direction
block
boolean
false
Whether to fill the parent width
disabled
boolean
false
Whether the whole group is disabled
class
string
—
Classes appended to the outer scroll container

Slots

Slot
Payload
Description
option
{ option: T }
Content of each item

Events

Event
Payload
Description
update:modelValue
value: string | number
The selected value changed