GrSegmented
A compact single-choice control with a pills/button presentation and a moving selection indicator.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- switching the view of one and the same thing — a list or a grid, a day or a month, a chart or a table;
- there are 2–5 options and all are visible — a choice without opening a panel and without an extra click;
- an option is an icon — display modes are recognised by a sign faster than by a word;
- the value goes into a form — a hidden field with
nameconnects the segments to a native submission.
When to take something else
| Need | Take |
|---|---|
| There are more than five options | GrSelect |
| This is the value of a field rather than a mode | GrRadioGroup |
| Sections with different content | GrTabs |
| There are two options and it is “on/off” | GrSwitch |
| Actions rather than a choice | GrButtonGroup |
Segments against tabs: segments change the view of one piece of content, tabs switch between different content. They are easy to confuse, and the user sees the difference at once — whether to expect the whole area to change depends on it.
The options and the slot
<GrSegmented v-model="view" :options="options" aria-label="List view" />
An option is { value, label?, icon?, disabled?, loading?, ariaLabel? }. The icon is decorative
(aria-hidden), so an icon-only segment requires ariaLabel: otherwise it has no name at all.
The default slot replaces the content of a segment and receives
{ option, selected, disabled, loading }.
A busy segment
{ value: 'review', label: 'Review', loading: syncing }
loading shows a spinner in place of the icon and marks the segment aria-busy. It does not
accept the selection, and the arrows step over it — as they do over a disabled one.
The difference from disabled is one of meaning, and it is visible to a screen reader: a busy
segment does not get aria-disabled and the native disabled — it is available, it is simply
working right now. An unavailable one gets both.
The native form
The value leaves in a single hidden field beside the segments (name) rather than nested into
every role="radio": the role declares its descendants presentational, and a nested interactive
control breaks the widget for screen readers. An unavailable selected segment does not send the
value.
Styling
| Prop | What it does |
|---|---|
variant | pills (the default) or button — a shadow on the track and on the indicator |
size | xs…lg, read from GrConfigProvider |
orientation | horizontal (the default) or vertical |
block | the segments stretch to the full width of the container |
indicatorDuration | the duration of the indicator animation, in ms |
Pointed customisation goes through --gr-segmented-* (the radius, the padding, the colours of the
track and of the indicator, the type size).
A long label is truncated but does not vanish
The label of a segment does not wrap: the row is obliged to stay a row. The tail that does not fit is hidden with an ellipsis, and “Included in the subscription” turns into “Included in…”.
The full text does not go anywhere in the process: truncate is a rendering rule, in the DOM the
string stays whole, and a screen reader reads it in full. It is lost by exactly one reader — the
one looking with their eyes — and to them the label is given with a native hint on hover. The hint
appears only when the text really is truncated: a tooltip duplicating a label that is fully
visible is noise nobody asked for.
The same handler is available from the outside — titleWhenTruncated from the package, for markup
of your own with truncate.
A vertical row
<GrSegmented v-model="scope" :options="filters" orientation="vertical" block />
The typical scenario is side filters. The row unfolds into a column, and the root announces
aria-orientation.
The indicator needed nothing for that: it is measured in two dimensions (translate3d plus
width/height) and travels downwards exactly as it does along a row. Changing the orientation on
the fly recomputes the geometry — otherwise the indicator would stay in the coordinates of the
previous layout.
In the vertical direction there is one column, so the segments are of equal width by construction,
and block decides only whether to take up the width of the container.
The radius of the track in the vertical direction is computed from the height of a segment rather
than taken as a pill: 9999px is tuned for a short row and on a tall column would turn the track
into an ellipse. The segments inside remain pills in the process — they compute their radius from
the same value.
The keyboard
←/↑ and →/↓ move the selection with a wrap over the edge, and Home/End go to the first
and the last available segment. Unavailable and busy ones are skipped.
Both axes work in any orientation — that is what the APG requires for a radiogroup: a
vertical row does not switch the horizontal arrows off and vice versa.
Playground 10
Loading…
<GrSegmented />Install
npm i @feugene/granularityImport
import { GrSegmented } from '@feugene/granularity/components/GrSegmented'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
variant | GrSegmentedVariant | undefined | undefined | — |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Read only: the choice is visible but does not change. |
invalid | boolean | undefined | false | The visual and ARIA state of an error. |
required | boolean | undefined | false | A mandatory field (`aria-required`). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
name | string | undefined | undefined | The name of the hidden field with which the chosen value goes into a native form. |
block | boolean | undefined | false | Stretch the segmented control to the full width of the container. |
orientation | "horizontal" | "vertical" | undefined | "horizontal" | The direction of the row. The vertical one is for side filters; the indicator is ready for it by construction, being two-dimensional. |
indicatorDuration | number | undefined | 300 | The duration of the animation of the indicator, in ms. |
modelValuerequired | GrSegmentedValue | — | — |
optionsrequired | GrSegmentedOption[] | — | — |
Slots
| Slot | Type | Description |
|---|---|---|
default | { option: GrSegmentedOption; selected: boolean; disabled: boolean; loading: boolean; } | The content of a segment instead of the label from `options`. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: GrSegmentedValue] | — |
change | [value: GrSegmentedValue, option: GrSegmentedOption] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Examples 5
Pills variant for compact view switching
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrSegmentedOption } from '@feugene/granularity'
import { GrBadge, GrSegmented } from '@feugene/granularity'
const period = ref<'day' | 'week' | 'month'>('week')
const options: GrSegmentedOption[] = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]
const selectionLabel = computed(() => options.find(option => option.value === period.value)?.label ?? period.value)
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div class="grid gap-4 rounded-[24px] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Revenue snapshot
</div>
<div class="mt-1 text-sm text-[var(--gr-muted-fg)]">
A switcher with a soft pills backing and an animated selected track.
</div>
</div>
<GrBadge tone="success" size="sm">
+12.4%
</GrBadge>
</div>
<GrSegmented v-model="period" :options="options" :indicator-duration="360" aria-label="Period" />
</div>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
Active segment:
<div class="mt-2 text-base font-semibold text-[var(--gr-fg)]">
{{ selectionLabel }}
</div>
</div>
</div>
</template>Button variant with runtime size control
<script setup lang="ts">
import { ref } from 'vue'
import type { GrSegmentedOption, GrSelectOption, GrSegmentedSize } from '@feugene/granularity'
import { GrFormField, GrSegmented, GrSelect } from '@feugene/granularity'
const view = ref<'board' | 'calendar' | 'table'>('board')
const size = ref<GrSegmentedSize>('md')
const indicatorDuration = ref('400')
const sizeOptions: GrSelectOption[] = [
{ value: 'xs', label: 'Extra small' },
{ value: 'sm', label: 'Small' },
{ value: 'md', label: 'Medium' },
{ value: 'lg', label: 'Large' },
]
const durationOptions: GrSelectOption[] = [
{ value: '200', label: 'Fast · 200 ms' },
{ value: '400', label: 'Balanced · 400 ms' },
{ value: '800', label: 'Smooth · 800 ms' },
]
const viewOptions: GrSegmentedOption[] = [
{ value: 'board', label: 'Board' },
{ value: 'calendar', label: 'Calendar' },
{ value: 'table', label: 'Table' },
]
</script>
<template>
<div class="grid gap-4">
<!-- Подписи через GrFormField, а не отдельным div: он выдаёт контролу id и
связывает с ним `<label for>`. Нарисованный рядом текст доступным именем
не становится — селект остаётся безымянным для скринридера. -->
<div class="grid gap-4 md:grid-cols-2 md:max-w-[520px]">
<GrFormField label="Segmented size">
<GrSelect v-model="size" :options="sizeOptions" />
</GrFormField>
<GrFormField label="Indicator speed">
<GrSelect v-model="indicatorDuration" :options="durationOptions" />
</GrFormField>
</div>
<GrSegmented
v-model="view"
:options="viewOptions"
variant="button"
:size="size"
:indicator-duration="Number(indicatorDuration)"
aria-label="View"
/>
</div>
</template>Icon + label and icon-only content
<script setup lang="ts">
import { ref } from 'vue'
import type { GrSegmentedOption } from '@feugene/granularity'
import { GrSegmented } from '@feugene/granularity'
import IconCalendarDays from '~icons/lucide/calendar-days'
import IconLayoutGrid from '~icons/lucide/layout-grid'
import IconRows3 from '~icons/lucide/rows-3'
import IconSunMoon from '~icons/lucide/sun-moon'
const dashboardView = ref<'board' | 'timeline' | 'calendar'>('board')
const iconOnlyView = ref<'board' | 'timeline' | 'calendar'>('timeline')
const dashboardOptions: GrSegmentedOption[] = [
{ value: 'board', label: 'Board', icon: IconLayoutGrid },
{ value: 'timeline', label: 'Timeline', icon: IconRows3 },
{ value: 'calendar', label: 'Calendar', icon: IconCalendarDays },
]
// Icon-only: иконка декоративна, поэтому имя сегмента задаётся явно —
// иначе скринридер объявит три пустые кнопки.
const iconOnlyOptions: GrSegmentedOption[] = [
{ value: 'board', icon: IconLayoutGrid, ariaLabel: 'Board' },
{ value: 'timeline', icon: IconRows3, ariaLabel: 'Timeline' },
{ value: 'calendar', icon: IconCalendarDays, ariaLabel: 'Calendar' },
]
</script>
<template>
<div class="grid gap-5 lg:grid-cols-2">
<div class="grid gap-3 rounded-[24px] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Icon + label
</div>
<GrSegmented
v-model="dashboardView"
:options="dashboardOptions"
:indicator-duration="260"
aria-label="Dashboard view"
/>
</div>
<div class="grid gap-3 rounded-[24px] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Icon-only with scoped slot
</div>
<GrSegmented
v-model="iconOnlyView"
:options="iconOnlyOptions"
size="sm"
:indicator-duration="420"
aria-label="Compact view switcher"
>
<template #default="{ option, selected }">
<component :is="option.icon ?? IconSunMoon" class="h-4 w-4" :class="selected ? '' : 'opacity-70'" />
</template>
</GrSegmented>
</div>
</div>
</template>Vertical orientation for sidebar filters
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrSegmentedOption, GrSegmentedOrientation } from '@feugene/granularity'
import { GrSegmented } from '@feugene/granularity'
const scope = ref('all')
const orientation = ref<GrSegmentedOrientation>('vertical')
const filters: GrSegmentedOption[] = [
{ value: 'all', label: 'All issues' },
{ value: 'mine', label: 'Assigned to me' },
{ value: 'review', label: 'In review' },
{ value: 'archived', label: 'Archived', disabled: true },
]
const orientations: GrSegmentedOption[] = [
{ value: 'vertical', label: 'Vertical' },
{ value: 'horizontal', label: 'Horizontal' },
]
const activeLabel = computed(() => filters.find(f => f.value === scope.value)?.label ?? scope.value)
</script>
<template>
<div class="grid gap-4">
<GrSegmented
v-model="orientation"
:options="orientations"
size="sm"
aria-label="Orientation"
/>
<div
class="grid gap-4 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4"
:class="orientation === 'vertical' ? 'md:grid-cols-[220px_minmax(0,1fr)]' : ''"
>
<GrSegmented
v-model="scope"
:options="filters"
:orientation="orientation"
:block="orientation === 'vertical'"
aria-label="Issue filter"
/>
<div class="text-sm text-[var(--gr-muted-fg)]">
Sidebar filters are the reason vertical exists. The indicator needed nothing new — it is measured in two
dimensions, so it slides down the column exactly as it slides across the row.
<div class="mt-2 text-base font-semibold text-[var(--gr-fg)]">
{{ activeLabel }}
</div>
</div>
</div>
</div>
</template>Disabled items, block layout and language switcher
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrSegmentedOption } from '@feugene/granularity'
import { GrButton, GrSegmented } from '@feugene/granularity'
const locale = ref<'ru' | 'en'>('ru')
const status = ref<'draft' | 'review' | 'published'>('review')
const localeOptions: GrSegmentedOption[] = [
{ value: 'ru', label: 'RU' },
{ value: 'en', label: 'EN' },
]
// `syncing` — сегмент занят: спиннер вместо иконки, выбор не принимается,
// стрелки его перешагивают.
const syncing = ref(false)
const statusOptions = computed<GrSegmentedOption[]>(() => [
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'Review', loading: syncing.value },
{ value: 'published', label: 'Published', disabled: true },
])
function syncReview() {
syncing.value = true
window.setTimeout(() => {
syncing.value = false
}, 2000)
}
const statusLabel = computed(() => statusOptions.value.find(option => option.value === status.value)?.label ?? status.value)
</script>
<template>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_240px]">
<div class="grid gap-4 rounded-[24px] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
<div class="grid gap-3">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Language switcher
</div>
<GrSegmented v-model="locale" :options="localeOptions" size="sm" :indicator-duration="220" aria-label="Language" />
</div>
<div class="grid gap-3">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Block layout + disabled item
</div>
<GrSegmented
v-model="status"
:options="statusOptions"
block
variant="button"
:indicator-duration="500"
aria-label="Publishing status"
/>
</div>
</div>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
Selected state:
<div class="mt-2 text-base font-semibold text-[var(--gr-fg)]">
{{ statusLabel }}
</div>
<div class="mt-3 text-sm">
The disabled option stays visible and keeps the structure of the choice set.
</div>
<GrButton class="mt-3" size="sm" variant="outline" :disabled="syncing" @click="syncReview">
Sync «Review» for 2s
</GrButton>
</div>
</div>
</template>Accessibility
- APG pattern
radiogroup