GrSegmented

Package: @feugene/granularitycoreGroup: forms

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 name connects the segments to a native submission.

When to take something else

NeedTake
There are more than five optionsGrSelect
This is the value of a field rather than a modeGrRadioGroup
Sections with different contentGrTabs
There are two options and it is “on/off”GrSwitch
Actions rather than a choiceGrButtonGroup

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.

Read-only and unavailable

readonly shows the selection but does not let it be changed — neither by a click nor from the keyboard; the group is announced aria-readonly. disabled dims the whole control.

Both states are dimmed with the --gr-disabled-fg token rather than with transparency: opacity dilutes text colours tuned to AA.

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

PropWhat it does
variantpills (the default) or button — a shadow on the track and on the indicator
sizexslg, read from GrConfigProvider
orientationhorizontal (the default) or vertical
blockthe segments stretch to the full width of the container
indicatorDurationthe 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…

Code
<GrSegmented />

Install

npm i @feugene/granularity

Import

import { GrSegmented } from '@feugene/granularity/components/GrSegmented'

API

Props

PropTypedefaultDescription
variantGrSegmentedVariant | undefinedundefined
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseRead only: the choice is visible but does not change.
invalidboolean | undefinedfalseThe visual and ARIA state of an error.
requiredboolean | undefinedfalseA mandatory field (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
namestring | undefinedundefinedThe name of the hidden field with which the chosen value goes into a native form.
blockboolean | undefinedfalseStretch 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.
indicatorDurationnumber | undefined300The duration of the animation of the indicator, in ms.
modelValuerequiredGrSegmentedValue
optionsrequiredGrSegmentedOption[]

Slots

SlotTypeDescription
default{ option: GrSegmentedOption; selected: boolean; disabled: boolean; loading: boolean; }The content of a segment instead of the label from `options`.

Events

EventTypeDescription
update:modelValue[value: GrSegmentedValue]
change[value: GrSegmentedValue, option: GrSegmentedOption]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeDescription
focus() => void
blur() => void

Examples 5

Pills variant for compact view switching

Revenue snapshot
A switcher with a soft pills backing and an animated selected track.
+12.4%
Active segment:
Week

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

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

Contentdepends on the showcase environment
<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

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.
All issues

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

Language switcher
Block layout + disabled item
Selected state:
Review
The disabled option stays visible and keeps the structure of the choice set.

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

Full keyboard contract of the package

Component documentationAll components