GrTabs
Switches between related content sections without leaving the page.
Overview
When to take it
- sections with different content — the profile, security, notifications: one is needed at a time;
- a tab has a counter or an icon —
badgeandiconwith no markup of your own; - the tabs close —
closablefor workspaces and open documents; - there are many tabs — they move into a scroll rather than breaking the layout.
When to take something else
| Need | Take |
|---|---|
| The view of one and the same thing is switched | GrSegmented |
| All of the sections are visible at once | GrCollapse |
| The sections of a form follow one another | GrFormSection |
| The transition leads to another screen | GrLink / GrSidebar |
| The panels themselves are needed | GrTabPanels |
Variants every tone and view, rendered by the real component
variant
pillsline
Sizes
xssmmdlg
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
<GrTabs :tabs="tabs" v-model="value" />Examples 2 each with a live demo and its code
Finance reports under a page title
Every tab runs a report, so
activation-mode="manual": the arrows only move the focus, Enter or Space picks the tab.variant="line"sits under a page title. Icons and counters come from the tab objects, a tab off the plan isdisabled. This component is the row alone: the content is the page’s own region, tied to the row byid-base.Invoices23 open invoices, €18,240 — 4 of them overdueOpen email templates
closableputs a cross on every tab and closes the focused one on Delete; the pinned README opts out withclosable: false. The row only reportsclose— the list stays ours, because a close may not happen. Thetabslot draws the file name and an unsaved dot, and the cross stays the row’s.<mj-text>Thanks, {{customer.name}} — invoice {{invoice.number}} is paid.</mj-text>
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
variant | "pills" | "line" | undefined | undefined | The look of the row: a holder with pills or a row with an underline. |
closable | boolean | undefined | false | A cross on the tabs and closing by `Delete`/`Backspace`. It is removed pointwise by `closable: false` on the tab itself. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The height of the tabs and the type size of the label by the controls scale. `md` by default. |
orientation | GrTabsOrientation | undefined | "horizontal" | A horizontal (the default) or a vertical list of tabs. |
activationMode | GrTabsActivationMode | undefined | "automatic" | `automatic` (the default) — an arrow switches the tab at once; `manual` — an arrow moves the focus only, and the choice is confirmed by `Enter`/`Space`. The second mode is for tabs with a heavy load: going through them with the arrows otherwise pulls every panel. |
emptyText | string | undefined | undefined | The text when the list of tabs is empty. The `#empty` slot is stronger. |
idBase | string | undefined | undefined | The base of the id for the ARIA link with `GrTabPanels`. If it is set, every tab gets an `id="<idBase>-tab-<value>"` and an `aria-controls="<idBase>-panel-<value>"`. Pass the same `idBase` to `GrTabPanels` to link `tab` to `tabpanel`. |
modelValuerequiredv-model | string | — | The `value` of the selected tab (`v-model`). If it matches none, `Tab` lands on the first available one. |
tabsrequired | GrTab[] | — | The tabs in display order: `value`, `label`, optionally an icon, a counter and `disabled`. |
Slots
| Slot | Type | Description |
|---|---|---|
tab | { tab: GrTab; active: boolean; disabled: boolean; } | The content of a tab as a whole — instead of the label, the icon and the counter. The cross stays. |
empty | any | An empty row — instead of the text from the locale. |
Events
| Event | Signature | Description |
|---|---|---|
close | (event: "close", value: string): void | — |
update:modelValue | (event: "update:modelValue", value: string): void | — |
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 { GrTab, GrTabsProps, GrTabsEmits } from '@feugene/granularity/components/GrTabs'Filedist/types/components/GrTabs/GrTabs.vue.d.ts
interface GrTab {
value: string;
label: string;
badge?: string;
icon?: string | Component;
disabled?: boolean;
closable?: boolean;
}interface GrTabsProps {
modelValue: string;
tabs: GrTab[];
idBase?: string;
size?: GrTabsSize;
variant?: GrTabsVariant;
activationMode?: GrTabsActivationMode;
orientation?: GrTabsOrientation;
closable?: boolean;
emptyText?: string;
}interface GrTabsEmits {
(e: 'update:modelValue', value: string): void;
(e: 'close', value: string): void;
}Filedist/types/components/GrTabs/config.d.ts
const grTabsConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTabs">;Filedist/types/components/GrTabs/defaults.d.ts
interface GrTabsConfigurableProps {
size: GrComponentSize;
variant: GrTabsVariant;
}Filedist/types/components/GrTabs/grTabsStyles.d.ts
type GrTabsSize = GrComponentSize;type GrTabsVariant = typeof GR_TABS_VARIANTS[number];type GrTabsOrientation = 'horizontal' | 'vertical';Filedist/types/components/GrTabs/safelist.d.ts
const grTabsSafelist: string[];Accessibility
APG pattern
tabs
Keys
Home / End
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
The look of the row
variant | What it is |
|---|---|
pills (the default) | pill tabs in a shared holder; the name is shared with GrSegmented |
line | the classic row with the active tab underlined |
variant is read from GrConfigProvider (componentDefaults.GrTabs.variant) — the look is set
once for the whole application.
The height of a tab repeats the scale of GrButton — 28 / 32 / 40 / 44 px for
xs / sm / md / lg: tabs often stand in one row with a button, and a difference of a few
pixels there reads as a layout error. The match is held by the src/__tests__/componentSize.test.ts
gate, because it has already diverged silently once.
Hence the consequence: size="lg" gives 44px — the size WCAG 2.5.5 and the Apple HIG require for a
target under a finger.
The content of a tab
The icon field of a tab is an icon to the left of the label; it is decorative (aria-hidden), and
the name of the tab is given by the text. The icon is set with a Vue component or with an icon class
(i-lucide-* — the application sets up the rule for it: in the engine
factory or through a provider, otherwise a Vue component; see “Icons”).
The #tab slot replaces the content as a whole and receives { tab, active, disabled }:
<GrTabs v-model="tab" :tabs="tabs">
<template #tab="{ tab, active }">
<GrAvatar :src="tab.avatar" size="xs" />
<span :class="active ? 'font-700' : ''">{{ tab.label }}</span>
</template>
</GrTabs>Closable tabs
<GrTabs v-model="tab" :tabs="tabs" closable @close="close" />closable switches closing on for the whole row; a closable: false on an individual tab removes
it (a pinned tab among closable ones), and a closable: true on a tab switches that one on alone
when the row has no prop. A disabled tab is closed by nothing: aria-disabled is about all
interaction, and closing is interaction too.
The cross is not a button. role="tab" declares its descendants presentational, so a nested
<button> is lost for a screen reader (axe: nested-interactive). The cross is an aria-hidden
<span>, and a click on it is handled by the handler of the tab itself: there is still one
interactive element in the row. From the keyboard closing is Delete or Backspace on the focused
tab; that the key works is reported by aria-keyshortcuts="Delete" on closable tabs. It is the
focused tab that closes, not the selected one: with activation-mode="manual" the two legitimately
differ.
The list stays with the consumer. The component emits only close(value) and touches neither
tabs nor modelValue: the closing may not take place (“save the changes?”), and the tab would
have switched for nothing.
function close(value: string) {
const index = tabs.value.findIndex(tab => tab.value === value)
tabs.value.splice(index, 1)
if (current.value === value)
current.value = tabs.value[index]?.value ?? tabs.value[index - 1]?.value ?? ''
}The focus after closing is returned by the component itself — but only when the list really did get
shorter and only if the closed tab held the focus. Otherwise closing from the keyboard would drop
the focus into <body> together with the button that vanished.
The #tab slot does not take the cross away: it lives outside the slot, so markup of your own for a
tab does not lose the closing.
An empty row
When tabs is empty, role="tablist" is not rendered at all — in its place there is a block
with text from the locale (gr.tabs.empty). The role is obliged to own tab children, and a text
node inside it is a violation of aria-required-children rather than an empty state. The block
holds the height of a tab, so that the neighbouring blocks do not jump when the last one is closed.
<GrTabs v-model="tab" :tabs="tabs" empty-text="No open files" />emptyText overrides the text of the locale, and the #empty slot overrides both.
Overflow
A horizontal row scrolls rather than wrapping onto a line: wrapping breaks the row and takes the tabs under the panel. The scrollbar is hidden — the row is run by the arrows, and the active tab pulls itself into the visible part, including when it was selected from the outside.
That there is more beyond the edge is given away by a fade: the edge behind which there are still tabs goes out — at the start of the row the right one, at the end the left one, in the middle both. If everything fits, nothing goes out. Without that sign the tabs beyond the edge remained reachable, but there was no way to learn that they were there.
The fade is made with a mask rather than with a gradient backdrop, and that follows from the
background of the strip being different in the variants: pills carries its own opaque
--gr-muted, while line is transparent and lies on the background of the parent, which the
component does not know. A gradient has nowhere to take the colour of the backdrop for line; a
mask fades the content regardless of what is beneath it.
The width of the fade is the --gr-tabs-scroll-fade hook (1.5rem by default). The same value sets
the scroll-padding of the row: a tab pulled into the visible part does not stand under the fade,
and the focus ring stays readable.
A vertical row does not scroll — it grows downwards — and has no fade.
The container needs no tabindex of its own: the tabs inside are reachable with the arrows, and the
focus scrolls the row along with it — the “a scrolling block is reachable from the keyboard” rule is
met through them rather than through a tab-order stop on the tablist itself.
The activation mode
<GrTabs v-model="tab" :tabs="tabs" activation-mode="manual" />automatic (the default) — an arrow switches the tab at once: the selection follows the focus, as
the APG requires. manual — an arrow moves only the focus, and the selection is confirmed with
Enter or Space. The second mode is needed by tabs with heavy loading: stepping through with the
arrows would otherwise pull every panel.
The orientation
orientation="vertical" unfolds the list into a column, announces aria-orientation and moves the
navigation to ↑/↓ instead of ←/→.
In a column the content of a tab is aligned to the start: the icons stand in an even line, the label takes up the rest, and the badge and the cross sit at the end. The vertical layout is a column of settings sections, and centred it read as a menu of buttons. In a row the tabs are still centred.
A disabled tab stays visible
The native disabled removed a tab both from the tab order and from being announced by a screen
reader — the user did not learn of its existence. The APG for tablist recommends the opposite: the
tab stays reachable and announced, and the unavailability is expressed by aria-disabled. It does
not accept the selection in the process and is skipped by the arrows.
The pairing with the panels
idBase sets the id scheme: a tab gets <idBase>-tab-<value> and
aria-controls="<idBase>-panel-<value>". The same idBase is passed into GrTabPanels — otherwise
the aria-controls ↔ aria-labelledby pairing does not add up (in a dev build the panel will warn
about that).
Why the focus is not lost
The array of references to the buttons is cleaned when Vue gives a null for a node that has
vanished, and it is trimmed to the length of the list of tabs. Without that, buttons detached from
the DOM stayed in it, and a focus() over the shortened list silently fell through into <body>.
Props in this example