GrTabs

Package: @feugene/granularitycoreGroup: navigation

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 iconbadge and icon with no markup of your own;
  • the tabs closeclosable for workspaces and open documents;
  • there are many tabs — they move into a scroll rather than breaking the layout.

When to take something else

NeedTake
The view of one and the same thing is switchedGrSegmented
All of the sections are visible at onceGrCollapse
The sections of a form follow one anotherGrFormSection
The transition leads to another screenGrLink / GrSidebar
The panels themselves are neededGrTabPanels

The look of the row

variantWhat it is
pills (the default)pill tabs in a shared holder; the name is shared with GrSegmented
linethe 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-controlsaria-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…

Code
<GrTabs />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
variant"pills" | "line" | undefinedundefinedThe look of the row: a holder with pills or a row with an underline.
closableboolean | undefinedfalseA 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" | undefinedundefined
orientationGrTabsOrientation | undefined"horizontal"A horizontal (the default) or a vertical list of tabs.
emptyTextstring | undefinedundefinedThe text when the list of tabs is empty. The `#empty` slot is stronger.
idBasestring | undefinedundefinedThe 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`.
activationModeGrTabsActivationMode | 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.
modelValuerequiredstring
tabsrequiredGrTab[]

Slots

SlotTypeDescription
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.
emptyanyAn empty row — instead of the text from the locale.

Events

EventTypeDescription
close[value: string]
update:modelValue[value: string]

Examples 7

Activation

Панель «Обзор»
В ручном режиме стрелки двигают только фокус — выбор подтверждает `Enter` или `Space`. Отключённая вкладка остаётся объявленной, но пропускается при переборе.

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

Overview tab is the default landing surface for a compact summary.
Active tab: overview

Basic Switch
<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

README.md is pinned (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.

Closable
<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

Queue: 12Reviews: 4Blocked: 2

Badge
<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

Service health summary
Tabs stay presentation-focused while the page decides which panel to render.
Uptime 99.96%

Panel Layout
<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

size="xs"
size="sm"
size="md"
size="lg"

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.

Overflow
<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

Full keyboard contract of the package

Component documentationAll components