<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>
Usage
import { SegmentedControl } from '@hina-ui/vue'
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>
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>
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>
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>
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>
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>
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>
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.
<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>
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 witharia-pressed. - Name the group with
aria-labeloraria-labelledby. With the#optionslot, every item is named by itslabel.
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 |