GrSegmented

  • core
  • forms
  • API · 15 props
  • ⌨ keys

A compact single-choice control with a pills/button presentation and a moving selection indicator.

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

Overview

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.

Variants every tone and view, rendered by the real component

variant

  • pills
  • button

Sizes

  • xs
  • sm
  • md
  • lg

States

  • normal
  • disabled
  • readonly
  • invalid

The grid is live: hover and press it. Hover, focus and active states are not drawn here — they belong to the browser, not to a prop.

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 4 each with a live demo and its code

  • One revenue, grouped and drawn your way

    Segments change how the same content is shown, never what it is — that is the line between them and tabs. One row groups the revenue by week, month or quarter, the other draws it as a chart or a table: icon-only segments carry an ariaLabel each, variant="button" lifts the indicator off the track, and change hands over the option, so the caption speaks its label.

    RevenueBy month · Sep €204.6k, +16.1%
    €168kMay
    €172kJun
    €182kJul
    €176kAug
    €205kSep
  • A review queue with counts

    block takes the width of the panel, and item-width="content" shares it by the labels: equal thirds would cut «Needs my review» while «All» kept room it does not need. The slot adds a count to each label. Until CI answers, «Failing checks» is loading: it cannot be chosen, the arrows skip it, yet it stays available — aria-busy, not aria-disabled.

    billing-service · pull requests
    • Retry failed webhooks with backoff#1842 · minaChecking…
    • Prorate seats added mid-cycle#1839 · danielChecking…
    • Dunning emails in the customer locale#1828 · graceChecking…
  • A billing period in a plain form

    Next to an input a pill reads as a guest from another system, so shape="box" takes the radius of the fields and block the width of the column. name sends the choice as one hidden input of a native <form>. A yearly contract starts at five seats — the rule spans two fields, so the page sets invalid. The currency is fixed by the first invoice: readonly keeps it in sight and in the form.

    Team planChanges apply from the next invoice

    €36 a month · €12 a seat

    Fixed after the first invoice

  • Support queues in a sidebar

    orientation="vertical" stacks the segments into a column of filters, and block gives them the width of the sidebar. The archive is not on this plan: a disabled option is skipped by the arrows. An empty queue offers a way out — focus() puts the keyboard on the chosen segment rather than the first, and the arrows go on from there.

    Assigned to me0
    Nothing is assigned to youNew conversations land in «All open» first.

API generated from the package · cannot drift

Props

PropTypedefaultDescription
disabledboolean | undefinedfalseThe row is unavailable: segments cannot be selected or focused. Also switched on by `GrFormField`.
variantGrSegmentedVariant | undefinedundefinedThe styling: `button` adds a shadow to the track and the indicator. `pills` by default.
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" | undefinedundefinedThe height and type size on the control scale. `md` by default; taken from `GrConfigProvider`.
ariaLabelstring | undefinedundefinedThe accessible name of the row. Not needed inside `GrFormField`: the label of the field names the row.
namestring | undefinedundefinedThe name of the hidden field with which the chosen value goes into a native form.
shape"box" | "pill" | undefinedundefinedThe shape of the border. `pill` — a pill track, as it has always been; `box` — the same rounding as the input fields have, so that the segments do not look like a guest from another system in a 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.
itemWidth"equal" | "content" | undefinedundefinedHow the width is divided in a `block` row: `equal` — evenly, as it has always been; `content` — by the content, with the free space divided on top of it. `equal` stays the default: with `content` the width of the row would depend on the length of the labels, and translating the interface would move the boundaries of the segments for everyone. Without `block` and in the vertical the prop means nothing — there the width is by the content anyway, and there is one column.
indicatorDurationnumber | undefined300The duration of the animation of the indicator, in ms.
modelValuerequiredv-modelGrSegmentedValue—The selected value (`v-model`). The arrows move the selection together with the focus.

Slots

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

Events

EventSignatureDescription
update:modelValue(event: "update:modelValue", value: GrSegmentedValue): void—
change(event: "change", value: GrSegmentedValue, option: GrSegmentedOption): void—
focus(event: "focus", event: FocusEvent): void—
blur(event: "blur", event: FocusEvent): void—

Methods

MethodsSignatureDescription
focus() => voidFocus the current `Tab` stop of the row — usually the selected segment, not the first one.
blur() => voidRemove the focus from the current segment of the row.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrSegmentedConfigurableProps, GrSegmentedVariant, GrSegmentedSize } from '@feugene/granularity/components/GrSegmented'

Filedist/types/components/GrSegmented/config.d.ts

grSegmentedConfigconst
const grSegmentedConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSegmented">;

Filedist/types/components/GrSegmented/defaults.d.ts

GrSegmentedConfigurablePropsinterface
interface GrSegmentedConfigurableProps {
    size: GrSegmentedSize;
    variant: GrSegmentedVariant;
    shape: GrControlShape;
    itemWidth: GrSegmentedItemWidth;
}

Filedist/types/components/GrSegmented/grSegmentedStyles.d.ts

GrSegmentedVarianttype
type GrSegmentedVariant = 'pills' | 'button';
GrSegmentedSizetype
type GrSegmentedSize = GrComponentSize;
GrSegmentedOrientationtype
type GrSegmentedOrientation = typeof GR_SEGMENTED_ORIENTATIONS[number];
GrSegmentedItemWidthtype
type GrSegmentedItemWidth = typeof GR_SEGMENTED_ITEM_WIDTHS[number];
GrSegmentedValuetype
type GrSegmentedValue = string | number;
GrSegmentedOptiontype
type GrSegmentedOption = {
    value: GrSegmentedValue;
    label?: string;
    icon?: Component;
    disabled?: boolean;
    loading?: boolean;
    ariaLabel?: string;
};

Filedist/types/components/GrSegmented/GrSegmented.vue.d.ts

GrSegmentedPropsinterface
interface GrSegmentedProps {
    modelValue: GrSegmentedValue;
    options: GrSegmentedOption[];
    variant?: GrSegmentedVariant;
    size?: GrSegmentedSize;
    shape?: GrControlShape;
    orientation?: GrSegmentedOrientation;
    itemWidth?: GrSegmentedItemWidth;
    indicatorDuration?: number;
    block?: boolean;
    disabled?: boolean;
    readonly?: boolean;
    invalid?: boolean;
    required?: boolean;
    name?: string;
    ariaLabel?: string;
}
GrSegmentedEmitsinterface
interface GrSegmentedEmits {
    (e: 'update:modelValue', value: GrSegmentedValue): void;
    (e: 'change', value: GrSegmentedValue, option: GrSegmentedOption): void;
    (e: 'focus', event: FocusEvent): void;
    (e: 'blur', event: FocusEvent): void;
}

Filedist/types/components/GrSegmented/safelist.d.ts

grSegmentedSafelistconst
const grSegmentedSafelist: string[];

Filedist/types/components/GrSegmented/index.d.ts

GrSegmentedInstancetype
type GrSegmentedInstance = ComponentExposed<typeof GrSegmentedComponent>;

Accessibility

APG pattern

radiogroup

Keys

← / → / ↑ / ↓Home / EndSpace / Enter

Usage from the library prose

The options and the slot
vue
<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
vue
{ 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.

invalid — its own or one coming from a GrFormField error — paints the border of the track and the focus ring with the error roles (--gr-invalid-brd, --gr-invalid-ring), as the input fields and GrRadioGroup do, and sets aria-invalid. A border colour of your own goes through --gr-segmented-invalid-brd.

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.

Who gets the free space

block stretches the row across the container, and itemWidth decides how it divides the width:

ValueWhat it does
equal (the default)the segments are equal — right for “Day / Week / Month”
contentthe width comes from the content, and the free space is divided on top of it
vue
<GrSegmented v-model="filter" :options="filters" item-width="content" block />

Equal tracks in a 400 px drawer give three cells of 121 px: the word “All” does not need that much, while “With documents” does not fit and is cut with an ellipsis — even though in total there is enough room. content lays it out where it is needed.

The default does not change, and that is deliberate. With content the width of the row depends on the length of the labels, and translating the interface would move the segment boundaries for every consumer at once.

Technically the mode is a minmax(0, auto) track. While there is enough room, the segments get the width of their content, and the remainder is divided equally on top. When there is not, the tracks shrink: short labels stay whole, while long ones share the remainder and go into an ellipsis with the full text in title. The row never becomes wider than the container.

The minimum of the track is zero rather than min-content: the label is single-line, and its min-content equals its max-content — with such a minimum the track did not shrink at all, and the row pushed the card around it past its edge.

Without block and in the vertical direction the prop means nothing: there the width follows the content anyway, and there is a single column.

The shape of the frame

The track is a pill, and that is the default: that is how segments have always looked. The second value is box, the same rounding as the input fields have: in a form beside a GrSelect a pill reads as a guest from another system.

vue
<GrSegmented v-model="view" :options="options" shape="box" />

The radius of a segment inside the track is derived (calc(radius − track padding)), so the second shape comes out by itself and needs no separate setting.

In the vertical direction the pill is computed by a formula rather than taking 9999px: on a tall column such a radius would turn the track into an ellipse, so it equals the height of one segment. box knows nothing of that correction — 6 px does not become an ellipse.

The axis is shared with the input fields — ../sizes.md.

Styling
PropWhat it does
variantpills (the default) or button — a shadow on the track and on the indicator
sizexs…lg, 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
vue
<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 content of a segment in a column sits at the start rather than in the centre: centred labels and icons of different lengths formed a ragged line, and the column read as a menu rather than a list. In a horizontal row a segment is still centred — there it reads as a button. The indicator island does not depend on the alignment: it measures the segment itself and covers it entirely.

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.