GrTabs
Switches between related content sections without leaving the page.
Machine-translated from the Russian original, not yet reviewed. Read the original
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 |
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
from your UnoCSS build (i-lucide-* — then your presetIcons is needed, 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.
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 ←/→.
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>.
Playground 6
Loading…
<GrTabs />Install
npm i @feugene/granularityImport
import { GrTabs } from '@feugene/granularity/components/GrTabs'API
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 | — |
orientation | GrTabsOrientation | undefined | "horizontal" | A horizontal (the default) or a vertical list of tabs. |
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`. |
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. |
modelValuerequired | string | — | — |
tabsrequired | GrTab[] | — | — |
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 | Type | Description |
|---|---|---|
close | [value: string] | — |
update:modelValue | [value: string] | — |
Examples 7
Activation
<script setup lang="ts">
import { ref } from 'vue'
import type { GrTabsOrientation } from '@feugene/granularity'
import { GrSegmented, GrTabPanel, GrTabPanels, GrTabs } from '@feugene/granularity'
const tab = ref('overview')
const orientation = ref<GrTabsOrientation>('horizontal')
const manual = ref(true)
const tabs = [
{ value: 'overview', label: 'Обзор' },
{ value: 'activity', label: 'Активность', badge: '12' },
{ value: 'archive', label: 'Архив', disabled: true },
{ value: 'billing', label: 'Счета' },
]
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-4">
<GrSegmented
v-model="orientation"
size="sm"
:options="[
{ value: 'horizontal', label: 'horizontal' },
{ value: 'vertical', label: 'vertical' },
]"
/>
<label class="flex items-center gap-2 text-sm text-[var(--gr-muted-fg)]">
<input v-model="manual" type="checkbox">
activationMode="manual"
</label>
</div>
<div class="flex flex-wrap items-start gap-4">
<GrTabs
v-model="tab"
:tabs="tabs"
:orientation="orientation"
:activation-mode="manual ? 'manual' : 'automatic'"
id-base="activation-demo"
/>
<GrTabPanels v-model="tab" id-base="activation-demo" class="min-w-[16rem] flex-1">
<GrTabPanel v-for="item in tabs" :key="item.value" :value="item.value">
Панель «{{ item.label }}»
</GrTabPanel>
</GrTabPanels>
</div>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
В ручном режиме стрелки двигают только фокус — выбор подтверждает `Enter` или `Space`.
Отключённая вкладка остаётся объявленной, но пропускается при переборе.
</div>
</div>
</template>Basic switching with controlled state
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrTabs } from '@feugene/granularity'
const currentTab = ref('overview')
const tabs = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'billing', label: 'Billing' },
]
const panelContent = computed(() => {
if (currentTab.value === 'activity')
return 'Activity tab usually hosts timelines, audits and operator actions.'
if (currentTab.value === 'billing')
return 'Billing tab is a natural place for invoices, payment status and limits.'
return 'Overview tab is the default landing surface for a compact summary.'
})
</script>
<template>
<div class="grid gap-3">
<GrTabs v-model="currentTab" :tabs="tabs" />
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-fg)] shadow-[var(--gr-shadow-1)]">
{{ panelContent }}
</div>
<GrBadge>
Active tab: {{ currentTab }}
</GrBadge>
</div>
</template>Closable tabs and the empty row
closable: false) and stays put — "Close all" empties the list to show the empty row. Click the close icon, or focus a tab and press Delete. <script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrTabs, type GrTab } from '@feugene/granularity'
const initial: GrTab[] = [
{ value: 'readme', label: 'README.md', icon: 'i-lucide-pin', closable: false },
{ value: 'index', label: 'index.ts' },
{ value: 'styles', label: 'styles.css' },
{ value: 'config', label: 'vite.config.ts' },
]
const tabs = ref<GrTab[]>([...initial])
const currentTab = ref('index')
/**
* Компонент эмитит только `close`: список — проп, и закрытие может не
* состояться («сохранить изменения?»). Выбор соседа тоже за потребителем.
*/
function close(value: string) {
const index = tabs.value.findIndex(tab => tab.value === value)
if (index < 0)
return
tabs.value.splice(index, 1)
if (currentTab.value === value)
currentTab.value = tabs.value[index]?.value ?? tabs.value[index - 1]?.value ?? ''
}
function closeAll() {
tabs.value = []
currentTab.value = ''
}
function restore() {
tabs.value = [...initial]
currentTab.value = 'index'
}
</script>
<template>
<div class="grid gap-3">
<GrTabs
v-model="currentTab"
:tabs="tabs"
closable
variant="line"
empty-text="No open files"
@close="close"
/>
<div class="flex flex-wrap items-center gap-3">
<GrButton size="sm" variant="secondary" :disabled="tabs.length === initial.length" @click="restore">
Reopen all
</GrButton>
<GrButton size="sm" variant="ghost-border" :disabled="tabs.length === 0" @click="closeAll">
Close all
</GrButton>
<span class="text-sm text-[var(--gr-muted-fg)]">
README.md is pinned (<code>closable: false</code>) and stays put — "Close all" empties the list to show the
empty row. Click the close icon, or focus a tab and press <kbd>Delete</kbd>.
</span>
</div>
</div>
</template>Tabs with badges for queue-like navigation
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrTabs } from '@feugene/granularity'
const currentTab = ref('queue')
const tabs = [
{ value: 'queue', label: 'Queue', badge: '12', icon: 'i-lucide-inbox' },
{ value: 'reviews', label: 'Reviews', badge: '4', icon: 'i-lucide-eye' },
{ value: 'blocked', label: 'Blocked', badge: '2', icon: 'i-lucide-ban' },
]
</script>
<template>
<div class="grid gap-3">
<GrTabs v-model="currentTab" :tabs="tabs" />
<GrTabs v-model="currentTab" :tabs="tabs" variant="line" />
<div class="flex flex-wrap gap-2">
<GrBadge v-for="tab in tabs" :key="tab.value" :tone="tab.value === currentTab ? 'primary' : 'neutral'">
{{ tab.label }}: {{ tab.badge }}
</GrBadge>
</div>
</div>
</template>Tabs as page-level panel switcher
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrButton, GrTabs } from '@feugene/granularity'
type ViewKey = 'summary' | 'incidents' | 'contacts'
const currentTab = ref<ViewKey>('summary')
const tabs = [
{ value: 'summary', label: 'Summary' },
{ value: 'incidents', label: 'Incidents', badge: '3' },
{ value: 'contacts', label: 'Contacts' },
] satisfies Array<{ value: ViewKey, label: string, badge?: string }>
const sectionTitle = computed(() => {
if (currentTab.value === 'incidents')
return 'Escalation queue'
if (currentTab.value === 'contacts')
return 'On-call contacts'
return 'Service health summary'
})
</script>
<template>
<div class="grid gap-4">
<GrTabs v-model="currentTab" :tabs="tabs" />
<div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 shadow-[var(--gr-shadow-1)]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-600 text-[var(--gr-fg)]">
{{ sectionTitle }}
</div>
<div class="text-sm text-[var(--gr-muted-fg)]">
Tabs stay presentation-focused while the page decides which panel to render.
</div>
</div>
<GrButton size="sm" variant="outline">
Refresh panel
</GrButton>
</div>
<div class="flex flex-wrap gap-2">
<GrBadge v-if="currentTab === 'summary'">
Uptime 99.96%
</GrBadge>
<GrBadge v-else-if="currentTab === 'incidents'" tone="warning">
3 incidents require follow-up
</GrBadge>
<GrBadge v-else>
5 contacts in rotation
</GrBadge>
</div>
</div>
</div>
</template>Sizes
<script setup lang="ts">
import { ref } from 'vue'
import { GrTabs } from '@feugene/granularity'
const sizes = ['xs', 'sm', 'md', 'lg'] as const
const active = ref('overview')
const tabs = [
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity', badge: '12' },
{ value: 'settings', label: 'Settings' },
]
</script>
<template>
<div class="grid gap-4">
<div v-for="size in sizes" :key="size" class="grid gap-2">
<div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
size="{{ size }}"
</div>
<GrTabs v-model="active" :tabs="tabs" :size="size" />
</div>
</div>
</template>Overflow
Ряд гаснет у того края, за которым есть продолжение: в начале — справа, в конце — слева, в середине — с обеих сторон. Прокрутите ряд и проследите, как затухание переезжает. Влезает целиком — не гаснет вовсе.
Полоса прокрутки у ряда скрыта намеренно: под вкладками она выглядит чужеродно, а на macOS система прячет её до начала прокрутки — то есть показала бы продолжение уже после того, как пользователь о нём догадался.
Вкладки за краем достижимы и без мыши: стрелки ведут по ряду, а активная вкладка сама подтягивается в видимую часть — в том числе когда её выбрали снаружи. Отступ прокрутки равен ширине затухания, поэтому кольцо фокуса не оказывается под ним. Ширина — хук --gr-tabs-scroll-fade.
<script setup lang="ts">
import { ref } from 'vue'
import { GrSegmented, GrTabs } from '@feugene/granularity'
/**
* Ширина контейнера, а не окна: переполнение считается по доступному месту,
* поэтому увидеть его можно не трогая размер браузера.
*
* Вариант переключается рядом намеренно. Полоса `pills` несёт свой непрозрачный
* фон, `line` прозрачна и лежит на фоне страницы — затухание обязано читаться на
* обоих, и именно поэтому оно сделано маской, а не градиентом-подложкой:
* градиенту было бы неоткуда взять цвет подложки для `line`.
*/
const width = ref('260')
const widths = [
{ value: '260', label: '260px' },
{ value: '375', label: '375px' },
{ value: '520', label: '520px' },
]
const variant = ref<'pills' | 'line'>('pills')
const variants = [
{ value: 'pills', label: 'pills' },
{ value: 'line', label: 'line' },
]
const active = ref('overview')
const tabs = [
{ value: 'overview', label: 'Обзор' },
{ value: 'security', label: 'Безопасность' },
{ value: 'notifications', label: 'Уведомления' },
{ value: 'plan', label: 'Тариф' },
{ value: 'account', label: 'Аккаунт' },
{ value: 'sessions', label: 'Сеансы' },
{ value: 'api', label: 'Ключи API' },
]
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-4">
<label class="grid gap-1 text-[length:var(--gr-control-text-sm)]">
<span class="showcase-demo-text">Ширина контейнера</span>
<GrSegmented v-model="width" :options="widths" size="sm" />
</label>
<label class="grid gap-1 text-[length:var(--gr-control-text-sm)]">
<span class="showcase-demo-text">Вид ряда</span>
<GrSegmented v-model="variant" :options="variants" size="sm" />
</label>
</div>
<div
data-demo-tabs-box
class="rounded-[var(--gr-radius-md)] border border-dashed border-[var(--gr-brd)] p-3"
:style="{ width: `${width}px`, maxWidth: '100%' }"
>
<GrTabs v-model="active" :tabs="tabs" :variant="variant" size="sm" />
</div>
<p class="showcase-demo-text text-sm">
Ряд гаснет <b>у того края, за которым есть продолжение</b>: в начале — справа, в конце —
слева, в середине — с обеих сторон. Прокрутите ряд и проследите, как затухание переезжает.
Влезает целиком — не гаснет вовсе.
<br><br>
Полоса прокрутки у ряда скрыта намеренно: под вкладками она выглядит чужеродно, а на macOS
система прячет её до начала прокрутки — то есть показала бы продолжение уже после того, как
пользователь о нём догадался.
<br><br>
Вкладки за краем <b>достижимы и без мыши</b>: стрелки ведут по ряду, а активная вкладка сама
подтягивается в видимую часть — в том числе когда её выбрали снаружи. Отступ прокрутки равен
ширине затухания, поэтому кольцо фокуса не оказывается под ним. Ширина — хук
<code>--gr-tabs-scroll-fade</code>.
</p>
</div>
</template>Accessibility
- APG pattern
tabs