Skip to content
Components

Tabs & segmented controls

Tabs change what you are looking at; a segmented control changes how the same content is shown.

Tabs

Arrow keys move between tabs, Home and End jump to the ends, and only the ACTIVE tab is in the tab order — so Tab moves out of the group rather than through every one of them. That roving tabindex is the part everyone leaves out. Try it with the keyboard.
Code
const TABS = [
  { id: "overview", label: "Overview" },
  { id: "constraints", label: "Constraints", badge: 5 },
  { id: "build", label: "Build" },
  { id: "history", label: "History", disabled: true, disabledReason: "Coming with saved projects" }
]

<GdTabs v-model="tab" :tabs="TABS" label="Parcel sections" />

Segmented control

The track is SUNKEN and the selected thumb is raised. Inverting that — a raised track with an inset thumb — is the single most common way this control gets drawn wrong.
Code
const MODES = [
  { id: "explore", label: "Explore uses" },
  { id: "specific", label: "Something specific" }
]

<GdSegmented v-model="mode" :options="MODES" label="Search mode" />

Choice list

The keyboard is the tabs keyboard — arrow keys move, Home and End jump, and only the chosen option is in the tab order — because both skins share useGdTabList and cannot drift apart. The titles WRAP, which is the reason this is not a row of tabs: a two-line question in an underline strip stops the strip being a strip.
Code
const AUDIENCES = [
  { id: "owner", eyebrow: "Homeowner", title: "What can I build on my own block?", meta: "lots assessed in the region", metaLead: "1.42 m" },
  { id: "buyer", eyebrow: "Buyer", title: "Is this listing worth what they are asking for it?", meta: "median days on market", metaLead: "38" }
]

<GdChoiceList v-model="audience" :options="AUDIENCES" label="Who you are" />

API

GdTabs

Props

PropTypeDefault
modelValuerequiredstring—

The active tab's `id`, as `v-model`. Required — a tablist with nothing selected has no roving tabindex to give.

tabsrequired{ id: string; label: string; badge?: number; disabled?: boolean; disabledReason?: string }[]—

The list. `badge` renders a count that goes accent on the active tab; `disabled` renders the tab visibly inert, and `disabledReason` becomes its `title`.

labelrequiredstring—

The accessible name for the tablist. A group of tabs with no name is an unlabelled region.

Events

update:modelValuestring

The newly selected tab's `id`. Emitted from a click, an arrow key, Home or End — never from a disabled tab. GdTabs has no slots: it owns the tab list, and the panels are the caller's.

GdSegmented

Props

PropTypeDefault
modelValuerequiredstring—

The chosen option's `id`, as `v-model`.

optionsrequired{ id: string; label: string }[]—

Two or three of them. Past four the segments get too narrow to label honestly.

labelrequiredstring—

The accessible name for the group. It is a `radiogroup`, so this is what a screen reader reads before "2 of 3".

Events

update:modelValuestring

The chosen option's `id`. No slots — the options are data.

GdChoiceList

Props

PropTypeDefault
modelValuerequiredstring—

The chosen option's `id`, as `v-model`. Required for the same reason it is on GdTabs — a tablist with nothing selected has no roving tabindex to give.

optionsrequired{ id: string; eyebrow?: string; title: string; meta?: string; metaLead?: string; disabled?: boolean }[]—

The choices. `title` WRAPS, which is the whole reason this exists rather than a row of tabs; `metaLead` is the leading fragment of `meta`, set in the mono face and full ink, for the figure in "1,240 lots assessed".

labelrequiredstring—

The accessible name for the list. It announces as a tablist, so this is what is read before "2 of 4".

controlsstring—

The `id` of the panel these choices control, written onto each option's `aria-controls`.

progressMsnumber | nullnull

The countdown rule drawn on the SELECTED option, in milliseconds. `null` draws nothing — a list that is not advancing must not imply that it is. The component does not own the timer; see Usage.

Events

update:modelValuestring

The newly chosen option's `id`. From a click, an arrow key, Home or End — never from a disabled option.

Usage Guidelines

  • Use tabs when each option reveals a different panel of content — Overview, Constraints, Build.
  • Use a segmented control when the content stays and only the lens changes.
  • Use a select instead past four options. The segments get too narrow to label honestly and start being truncated.
  • Disable a tab rather than removing it when the section is coming, and give it a disabledReason.
  • Always pass label. It is the accessible name of the group, and it is required on both components.
  • Render the panels yourself. GdTabs owns the tab list and nothing else — give each panel id="{tabId}-panel" to match the aria-controls it writes.
  • Reach for GdChoiceList when an option needs more than a label — a question that wraps to two lines, a figure under it. Same contract, same composable, different shape.
  • Keep the auto-advance timer yourself. GdChoiceList draws the countdown you pass in progressMs and owns nothing else about it: when to start, whether a hover pauses it and whether the first click ends it for good are policy about one page, not about a list of choices.

Behavior & Anatomy

Which control

Reach for tabs when each option reveals a different panel of content — Overview, Constraints, Build. Reach for a segmented control when the content stays and only the lens changes.

The underline lives on the tab and is transparent when inactive, so selecting a tab does not change its height by 2px and shift the row beneath it.

Why the segmented control is not a second set of tabs

Two or three options. Past four it is a select — the segments get too narrow to label honestly and start being truncated.

It carries radio semantics rather than tab semantics: it picks a value, it does not reveal a panel, and a screen reader should say "2 of 3".

Navigate

Esc