GrSegmented
A compact single-choice control with a pills/button presentation and a moving selection indicator.
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
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.
Variants every tone and view, rendered by the real component
variant
pillsbutton
Sizes
xssmmdlg
States
normaldisabledreadonlyinvalid
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
Loading…
0 changed· state is in the URL
<GrSegmented
:options="options"
v-model="value"
ariaLabel="Calendar range"
/>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
ariaLabeleach,variant="button"lifts the indicator off the track, andchangehands over the option, so the caption speaks its label.RevenueBy month · Sep €204.6k, +16.1% €168kMay€172kJun€182kJul€176kAug€205kSepA review queue with counts
blocktakes the width of the panel, anditem-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» isloading: it cannot be chosen, the arrows skip it, yet it stays available —aria-busy, notaria-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 andblockthe width of the column.namesends 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 setsinvalid. The currency is fixed by the first invoice:readonlykeeps it in sight and in the form.Support queues in a sidebar
orientation="vertical"stacks the segments into a column of filters, andblockgives 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
| Prop | Type | default | Description |
|---|---|---|---|
disabled | boolean | undefined | false | The row is unavailable: segments cannot be selected or focused. Also switched on by `GrFormField`. |
variant | GrSegmentedVariant | undefined | undefined | The styling: `button` adds a shadow to the track and the indicator. `pills` by default. |
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 | The height and type size on the control scale. `md` by default; taken from `GrConfigProvider`. |
ariaLabel | string | undefined | undefined | The accessible name of the row. Not needed inside `GrFormField`: the label of the field names the row. |
name | string | undefined | undefined | The name of the hidden field with which the chosen value goes into a native form. |
shape | "box" | "pill" | undefined | undefined | The 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. |
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. |
itemWidth | "equal" | "content" | undefined | undefined | How 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. |
indicatorDuration | number | undefined | 300 | The duration of the animation of the indicator, in ms. |
modelValuerequiredv-model | GrSegmentedValue | — | The selected value (`v-model`). The arrows move the selection together with the focus. |
optionsrequired | GrSegmentedOption[] | — | The segments: value, label, icon, `disabled`, `loading`. An icon-only segment needs an `ariaLabel`. |
Slots
| Slot | Type | Description |
|---|---|---|
default |
| The content of a segment instead of the label from `options`. |
Events
| Event | Signature | Description |
|---|---|---|
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
| Methods | Signature | Description |
|---|---|---|
focus | () => void | Focus the current `Tab` stop of the row — usually the selected segment, not the first one. |
blur | () => void | Remove 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 type { GrSegmentedConfigurableProps, GrSegmentedVariant, GrSegmentedSize } from '@feugene/granularity/components/GrSegmented'Filedist/types/components/GrSegmented/config.d.ts
const grSegmentedConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSegmented">;Filedist/types/components/GrSegmented/defaults.d.ts
interface GrSegmentedConfigurableProps {
size: GrSegmentedSize;
variant: GrSegmentedVariant;
shape: GrControlShape;
itemWidth: GrSegmentedItemWidth;
}Filedist/types/components/GrSegmented/grSegmentedStyles.d.ts
type GrSegmentedVariant = 'pills' | 'button';type GrSegmentedSize = GrComponentSize;type GrSegmentedOrientation = typeof GR_SEGMENTED_ORIENTATIONS[number];type GrSegmentedItemWidth = typeof GR_SEGMENTED_ITEM_WIDTHS[number];type GrSegmentedValue = string | number;type GrSegmentedOption = {
value: GrSegmentedValue;
label?: string;
icon?: Component;
disabled?: boolean;
loading?: boolean;
ariaLabel?: string;
};Filedist/types/components/GrSegmented/GrSegmented.vue.d.ts
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;
}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
const grSegmentedSafelist: string[];Filedist/types/components/GrSegmented/index.d.ts
type GrSegmentedInstance = ComponentExposed<typeof GrSegmentedComponent>;Accessibility
APG pattern
radiogroup
Keys
← / → / ↑ / ↓Home / EndSpace / Enter
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
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.
Who gets the free space
block stretches the row across the container, and itemWidth decides how it divides the width:
| Value | What it does |
|---|---|
equal (the default) | the segments are equal — right for “Day / Week / Month” |
content | the width comes from the content, and the free space is divided on top of it |
<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.
<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
| 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 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.
Props in this example