Tabs
Swaps between panels of content that share one region of the page.
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.
<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.Tabinside aTabs.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.activateOnFocustofalseso 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 ownelementIdwhere two values differ only in punctuation.
Anatomy
Assemble the parts, one Tabs.Content per Tabs.Tab.
| Name | Required | Description |
|---|---|---|
| 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.Indicator | Positions 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
Options
Emits
Slot props
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
Tabs.Tab
Renders a <button role="tab">.
Props
Slot props
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
Tabs.Content
Renders a <div role="tabpanel">.
Props
Slot props
Composable
useMirrorTabs(id) reaches a Tabs from anywhere in the app.
Data attributes
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.
Errors
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.
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.