Skip to content

Tabs

Swaps between panels of content that share one region of the page.

View source View as Markdown

Tabs shows one panel at a time in a shared region, with a list of tabs to switch between them. Useful when several views belong in the same place and the user decides which one to see.

Three panels sharing one region, and one of them at a time.
App.vue
<template>
  <Tabs.Root v-model="tab" class="flex w-72 flex-col gap-4">
    <Tabs.List :class="list" aria-label="Sections">
      <Tabs.Tab
        v-for="entry in entries"
        :key="entry.value"
        :class="trigger"
        :disabled="entry.disabled"
        :value="entry.value"
      >
        {{ entry.label }}
      </Tabs.Tab>

      <Tabs.Indicator :class="indicator" />
    </Tabs.List>

    <Tabs.Content
      v-for="entry in entries"
      :key="entry.value"
      :class="content"
      :value="entry.value"
    >
      {{ entry.body }}
    </Tabs.Content>
  </Tabs.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Tabs } from '@maas/mirror/vue'
const tab = ref('overview')

const entries = [
  {
    value: 'overview',
    label: 'Overview',
    body: 'Three panels sharing one region, and one of them at a time.',
  },
  {
    value: 'tokens',
    label: 'Tokens',
    body: 'Arrow keys move between the tabs and activate as they go.',
  },
  { value: 'archive', label: 'Archive', body: 'Nothing here.', disabled: true },
]

const list =
  'rounded-component-sm border-surface relative isolate flex gap-1 border p-1'

const trigger = [
  'rounded-component-compact-md type-component-sm text-surface-muted relative z-10',
  'flex-1 px-3 py-1.5 transition-colors duration-200 ease-in-out',
  'border-2 border-[transparent] outline outline-4 outline-transparent',
  'focus-visible:focus-ring data-[active=true]:text-surface',
  'enabled:not-data-[active=true]:hover:bg-primary-subtle',
  'enabled:not-data-[active=true]:hover:text-surface',
  'enabled:not-data-[active=true]:active:bg-primary-subtle-hover',
  'data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40',
].join(' ')

const indicator = [
  'rounded-component-compact-md bg-primary-muted',
  'left-0! translate-x-[var(--mirror-tabs-indicator-left)]',
  'transition-transform duration-200 ease-out',
].join(' ')

const content = 'type-surface-body-md text-surface-muted'
</script>

Usage guidelines

  • Keep every Tabs.Tab inside a Tabs.List, since the list is the roving-focus group and a tab outside it has nothing to rove within.
  • If a content part is expensive to render, set options.activateOnFocus to false so the arrow keys move focus without activating and Enter or Space commits.
  • Characters outside A-Z, a-z, 0-9, _ and - are folded to - when the IDs are derived, so give a tab its own elementId where two values differ only in punctuation.

Anatomy

Assemble the parts, one Tabs.Content per Tabs.Tab.

NameRequiredDescription
Tabs.Root true Renders a <div>.
Tabs.List true Renders a <div role="tablist"> and is the roving-focus group.
Tabs.Tab true Renders a <button role="tab">. One per content part.
Tabs.IndicatorPositions itself from the active tab’s geometry.
Tabs.Content true Renders a <div role="tabpanel">. One per tab.
<script setup lang="ts">
import { Tabs } from '@maas/mirror/vue'
</script>

<template>
  <Tabs.Root v-model="tab">
    <Tabs.List>
      <Tabs.Tab value="overview">Overview</Tabs.Tab>
      <Tabs.Tab value="tokens">Tokens</Tabs.Tab>
      <Tabs.Tab value="archive" disabled>Archive</Tabs.Tab>
      <Tabs.Indicator />
    </Tabs.List>
    <Tabs.Content value="overview">…</Tabs.Content>
    <Tabs.Content value="tokens">…</Tabs.Content>
    <Tabs.Content value="archive">…</Tabs.Content>
  </Tabs.Root>
</template>

Examples

Moving between tabs

options.orientation decides which arrow keys move, and is written to data-orientation on every part, so lay the list out from that attribute rather than from a class of your own.

<template>
  <Tabs.Root v-model="tab" :options="{ orientation: 'vertical' }">
    <Tabs.List>
      <Tabs.Tab value="overview">Overview</Tabs.Tab>
      <Tabs.Tab value="tokens">Tokens</Tabs.Tab>
    </Tabs.List>
    <Tabs.Content value="overview">…</Tabs.Content>
    <Tabs.Content value="tokens">…</Tabs.Content>
  </Tabs.Root>
</template>

Arrow keys wrap at the ends. Set options.loopFocus to false to stop there instead, and options.dir to mirror the horizontal keys. Unset, options.dir follows a DirectionProvider or the surrounding dir attribute, as described in Composition.

<template>
  <Tabs.Root v-model="tab" :options="{ loopFocus: false, dir: 'rtl' }" />
</template>

Activating on focus

Moving focus activates by default. If a content part fetches or charts on the way in, set options.activateOnFocus to false and the arrow keys only move focus.

<template>
  <Tabs.Root v-model="tab" :options="{ activateOnFocus: false }" />
</template>

The tab stop stays on the active tab while focus moves, so leaving the list and coming back returns to the tab the user last activated.

Keeping panels mounted

force-mount on Tabs.Content holds that panel in the DOM behind the hidden attribute, so the browser’s in-page search still finds the content and any state inside it survives the swap.

<template>
  <Tabs.Root v-model="tab">
    <Tabs.Content force-mount value="tokens">…</Tabs.Content>
    <Tabs.Content value="archive">…</Tabs.Content>
  </Tabs.Root>
</template>

Each panel decides for itself, and a kept one costs the render it would have saved, so reach for it where what is inside is worth keeping rather than by default. To give several panels the same treatment, spread one object over each of them.

<script setup lang="ts">
const panel = { forceMount: true }
</script>

<template>
  <Tabs.Root v-model="tab">
    <Tabs.Content v-bind="panel" value="overview">…</Tabs.Content>
    <Tabs.Content v-bind="panel" value="tokens">…</Tabs.Content>
  </Tabs.Root>
</template>

Transitioning the swap

transition on Tabs.Content is a Vue transition name, so the timing lives in CSS under that name while data-state flips between open and closed.

<template>
  <Tabs.Root v-model="tab">
    <Tabs.Content transition="tab-content" value="tokens">…</Tabs.Content>
  </Tabs.Root>
</template>

<style>
.tab-content-enter-active,
.tab-content-leave-active {
  transition: opacity 150ms ease;
}

.tab-content-enter-from,
.tab-content-leave-to {
  opacity: 0;
}
</style>

Every panel names its own transition, so two of them can leave under different CSS. force-mount wins over the name, since a panel that never leaves the DOM has nothing to transition.

Positioning the indicator

Tabs.Indicator measures the active tab and positions itself on its own element, so the paint is all you supply.

<template>
  <Tabs.List>
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="tokens">Tokens</Tabs.Tab>
    <Tabs.Indicator class="indicator" />
  </Tabs.List>
</template>

<style>
.indicator {
  border-radius: var(--app-dimension-radius-md);
  background: var(--app-color-primary-bg-subtle);
  transition: all 200ms ease-out;
}
</style>

It renders nothing until the first measurement, so it never flashes at the origin during hydration, and there is no indicator in the server-rendered markup. It measures again whenever the list or the active tab resizes, a tab joins or leaves the list, the list scrolls, or the orientation or direction flips.

The same measurement is written to the indicator’s own element as six read-only custom properties. Reach for them when the default top, left and size are not the geometry you want, for example to slide the indicator by transform instead, which keeps the movement off the layout path.

.indicator {
  left: 0;
  translate: var(--mirror-tabs-indicator-left) 0;
  transition: translate 200ms ease-out;
}

data-activation-direction says which way the active tab moved, so read it to animate directionally.

.indicator[data-activation-direction='right'] {
  transform-origin: left;
}

Decoration of your own reads the same numbers as an object, from the indicator’s slot props or through useMirrorTabs(id). The composable reports null until the first measurement, and stays null where no Tabs.Indicator is rendered.

<template>
  <Tabs.Indicator v-slot="{ rect }" as="div">
    <span :style="{ width: `${rect.width}px` }" />
  </Tabs.Indicator>
</template>

Reacting to changes

update:modelValue fires when the active tab changes and only then, so activating the tab that is already active emits nothing.

<template>
  <Tabs.Root v-model="tab" @update:model-value="load" />
</template>

Leave modelValue and defaultValue off entirely and the first tab that can take it becomes active, one tick after the root first renders.

null is the way to say no tab at all, as opposed to leaving the value unset and letting the module pick.

<template>
  <Tabs.Root :model-value="null" />
</template>

Remove the active tab, or disable it, and Tabs activates the nearest tab that can take its place, searching forwards from its position and then back. That reselection is emitted as an ordinary update:modelValue, so a controlled parent decides whether it happens, and a value naming no tab is handled the same way. If every remaining tab is disabled, the value stays as it is.

API reference

Module. A bundled options object, and useMirrorTabs(id) as the programmatic API. Every part takes id to resolve a Tabs it is not nested inside.

Tabs.Root

Renders a <div>.

Props

PropTypeDefault
id
stringgenerated
modelValue
string | number | null | undefinedundefined
defaultValue
string | number | null | undefinedfirst selectable tab
options
TabsOptionssee below

Options

OptionTypeDefault
orientation
'horizontal' | 'vertical''horizontal'
dir
'ltr' | 'rtl'inherited
activateOnFocus
booleantrue
loopFocus
booleantrue

Emits

EmitPayload
update:modelValue
string | number | null

Slot props

PropType
value
string | number | undefined
orientation
'horizontal' | 'vertical'
activation-direction
'left' | 'right' | 'up' | 'down' | 'none'

activation-direction is a hyphenated key, so destructure it as { 'activation-direction': direction }. Every part that publishes it does the same.

Tabs.List

Renders a <div role="tablist"> and is the roving-focus group. No props beyond id and the primitive ones.

Slot props

PropType
orientation
'horizontal' | 'vertical'
activation-direction
'left' | 'right' | 'up' | 'down' | 'none'

Tabs.Tab

Renders a <button role="tab">.

Props

PropTypeDefault
id
stringinjected
elementId
stringderived
value
string | numbernone
disabled
booleanfalse

Slot props

PropType
active
boolean
disabled
boolean
index
number
orientation
'horizontal' | 'vertical'
activation-direction
'left' | 'right' | 'up' | 'down' | 'none'

Tabs.Indicator

Renders a <span aria-hidden="true">, positioned from the active tab’s geometry and absent until the first measurement. No props beyond id and the primitive ones.

Slot props

PropType
rect
TabsIndicatorRect
orientation
'horizontal' | 'vertical'
activation-direction
'left' | 'right' | 'up' | 'down' | 'none'

Tabs.Content

Renders a <div role="tabpanel">.

Props

PropTypeDefault
id
stringinjected
elementId
stringderived
value
string | numbernone
forceMount
booleanfalse
transition
stringundefined

Slot props

PropType
active
boolean
index
number
hidden
boolean
orientation
'horizontal' | 'vertical'
activation-direction
'left' | 'right' | 'up' | 'down' | 'none'

Composable

useMirrorTabs(id) reaches a Tabs from anywhere in the app.

KeyType
value
ComputedRef<TabsValue | undefined>
orientation
ComputedRef<TabsOrientation>
activationDirection
ComputedRef<TabsActivationDirection>
indicator
ComputedRef<TabsIndicatorRect | null>
activate
(next: TabsValue) => void

Data attributes

PartAttributeValue
all
data-orientation
horizontal | vertical
all
data-activation-direction
left | right | up | down | none
Tabs.Tab, Tabs.Content
data-active
true
Tabs.Tab
data-disabled
true
Tabs.Tab, Tabs.Content
data-index
the index
Tabs.Content
data-state
open | closed

CSS variables

Tabs.Indicator writes its measurement onto its own element. The variables are there to read, and setting them has no effect, since the component overwrites them on every measurement.

PartVariableDefault
Tabs.Indicator
--mirror-tabs-indicator-left
measured
Tabs.Indicator
--mirror-tabs-indicator-top
measured
Tabs.Indicator
--mirror-tabs-indicator-right
measured
Tabs.Indicator
--mirror-tabs-indicator-bottom
measured
Tabs.Indicator
--mirror-tabs-indicator-width
measured
Tabs.Indicator
--mirror-tabs-indicator-height
measured

Errors

Code
missing_tabs_context
duplicate_tab_value
missing_tab_content

Accessibility

The list is a role="tablist", each tab a role="tab" with aria-selected and aria-controls, and each panel a role="tabpanel" with aria-labelledby, and the whole list is one tab stop pinned to the active tab.

Every key below is handled on the tab itself, on keydown.

KeyBehaviour
TabMoves into the list at the active tab, then out to the active panel.
ArrowRight / ArrowLeftHorizontal list only. Moves to the next or previous enabled tab, swapped under dir="rtl". Wraps while options.loopFocus is on.
ArrowDown / ArrowUpVertical list only. Moves to the next or previous enabled tab, with the same wrapping.
Home / EndMoves to the first or last enabled tab, in either orientation, ignoring options.loopFocus.
Enter / SpaceActivates the focused tab, and prevents the default either way. Only meaningful with options.activateOnFocus off.

The active panel is tabindex="0" when it holds no focusable element, so Tab from the list always reaches the panel. aria-controls names the panel whether or not it is mounted, so force-mount changes only what is rendered.