Appearance
Appearance
import Nav from '~/ui/Nav.vue'A group of links (such as a table of contents or a navigation bar) or tabs.
You can add a badge or an icon to each tab link.
<script setup lang="ts" generic="T extends 'tabs' | 'links'">
import { computed, useId, nextTick } from 'vue'
import { type RouterLinkProps, useRoute, useRouter } from 'vue-router'
import { to } from '~/routing'
import Link from '~/ui/Link.vue'
import Button from '~/ui/Button.vue'
import Layout from '~/ui/Layout.vue'
import Badge from '~/ui/Badge.vue'
type Tab = {
title: string
name?: string
icon?: string
badge?: string | number | false
to?: RouterLinkProps['to']
}
/**
* Use the `tabQueryField` prop and the `tabpanels` slot-prop to create accessible tabs.
*/
const props = defineProps<{
tabQueryField?: string
}>()
/**
* Use `name` if you prefer strings over index positions in the URL parameter.
* Make sure the names are unique and conform to URL parameter name specs.
*/
const tabs = defineModel<Tab[]>({ required: true })
const isTabs = !!props.tabQueryField
const router = useRouter()
const route = useRoute()
const id = useId();
const currentIndex = computed(() => {
if (isTabs) {
const queryValue = route.query[props.tabQueryField]
const singleValue = Array.isArray(queryValue)
? queryValue[0] ?? ''
: queryValue ?? ''
const currentIndex = tabs.value.findIndex((tab, index) =>
'name' in tab
? tab.name === singleValue
: index === Number.parseInt(singleValue)
)
return currentIndex >= 0 ? currentIndex : 0
} else { return tabs.value.findIndex(tab =>
'to' in tab && tab.to && router.resolve(tab.to).path === route.path
)
}
})
const navigateTo = (index: number) => {
if (index < 0) index = tabs.value.length - 1
if (index > tabs.value.length - 1) index = 0
if (isTabs) {
const tab = tabs.value[index]
router.replace({
...route,
query: {
...route.query,
[props.tabQueryField]:
tab && 'name' in tab
? tab.name
: index
}
})
} else {
const targetTab = tabs.value[index];
if (targetTab && targetTab.to) {
router.replace(targetTab.to);
}
}
nextTick(() => {
if (typeof window === 'undefined') return // SSR early return
document.getElementById(id + index + 'tab')?.focus()
})
}
const computedTabs = computed(() => tabs.value.map((tab, index) => ({
...tab,
'key': index,
'id': id + index + 'tab',
'aria-selected': currentIndex.value === index ? 'true' : 'false',
'tabindex': index === currentIndex.value ? '0' : '-1',
'onKeydown': (e: KeyboardEvent) => {
if (['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'].includes(e.key))
e.preventDefault()
if (['ArrowRight', 'ArrowDown'].includes(e.key))
return navigateTo(index + 1)
if (['ArrowLeft', 'ArrowUp'].includes(e.key))
return navigateTo(index - 1)
if (['Home'].includes(e.key))
return navigateTo(0)
if (['End'].includes(e.key))
return navigateTo(Number.MAX_SAFE_INTEGER)
},
...(isTabs ? ({
'role': 'tab',
'aria-controls': id + index + 'tabpanel',
'aria-posinset': index + 1,
'aria-setsize': tabs.value.length,
'aria-pressed': undefined,
'_____myIndex': index,
'_____currentIndex': currentIndex.value,
'onClick': () => {
navigateTo(index)
}
}) : 'to' in tab && tab.to ? to(tab.to) : ({}))
} as const)
))
const tabpanels = computed(() => tabs.value.map((_, index) => ({
'aria-labelledby': id + index + 'tab',
'role': 'tabpanel',
'id': id + index + 'tabpanel',
'key': id + index,
'isActive': currentIndex.value === index
} as const)
))
</script>
<template>
<Layout
:id
nav
flex
:role="isTabs ? 'tablist' : undefined"
:class="$style.tablist"
>
<component
:is="isTabs ? Button : Link"
v-for="{ badge, ...tab } in computedTabs"
v-bind="tab"
:key="tab.key"
class="color-default-ghost shape-field-minimal"
ui-block-size="40"
ui-no-underline
>
<Layout
stack
no-gap
>
<span
:class="$style.falseTitle"
aria-hidden="true"
>{{ tab.title }}</span>
<span :class="$style.realTitle">{{ tab.title }}</span>
</Layout>
<Badge v-if="badge">
{{ badge }}
</Badge>
</component>
</Layout>
<slot
:current-index
:tabpanels
/>
</template>
<style module>
.tablist {
.realTitle {
font-size: 16px;
font-weight: 400;
}
.falseTitle[aria-hidden="true"] {
font-size: 16px;
font-weight: 900;
opacity: 0;
pointer-events: none;
max-height: 0;
overflow: hidden;
}
/* TODO: Add this variant to Button.vue and Link.vue */
button, a {
color: var(--color-700);
background: transparent;
padding: 0;
&:hover{
color: var(--color-950);
}
}
:is([aria-selected="true"], [aria-current]:not([aria-current="false"])){
.realTitle {
font-weight: 900;
&:after {
content: '';
display: block;
height: 4px;
background-color: var(--fw-orange-500);
margin: 0 auto;
width: calc(10% + 2rem);
position: absolute;
inset: auto 0 -2px 0;
border-radius: 100vh;
}
}
}
}
</style><script setup lang="ts" generic="T extends 'tabs' | 'links'">
import { computed, useId, nextTick } from 'vue'
import { type RouterLinkProps, useRoute, useRouter } from 'vue-router'
import { to } from '~/routing'
import Link from '~/ui/Link.vue'
import Button from '~/ui/Button.vue'
import Layout from '~/ui/Layout.vue'
import Badge from '~/ui/Badge.vue'
type Tab = {
title: string
name?: string
icon?: string
badge?: string | number | false
to?: RouterLinkProps['to']
}
/**
* Use the `tabQueryField` prop and the `tabpanels` slot-prop to create accessible tabs.
*/
const props = defineProps<{
tabQueryField?: string
}>()
/**
* Use `name` if you prefer strings over index positions in the URL parameter.
* Make sure the names are unique and conform to URL parameter name specs.
*/
const tabs = defineModel<Tab[]>({ required: true })
const isTabs = !!props.tabQueryField
const router = useRouter()
const route = useRoute()
const id = useId();
const currentIndex = computed(() => {
if (isTabs) {
const queryValue = route.query[props.tabQueryField]
const singleValue = Array.isArray(queryValue)
? queryValue[0] ?? ''
: queryValue ?? ''
const currentIndex = tabs.value.findIndex((tab, index) =>
'name' in tab
? tab.name === singleValue
: index === Number.parseInt(singleValue)
)
return currentIndex >= 0 ? currentIndex : 0
} else { return tabs.value.findIndex(tab =>
'to' in tab && tab.to && router.resolve(tab.to).path === route.path
)
}
})
const navigateTo = (index: number) => {
if (index < 0) index = tabs.value.length - 1
if (index > tabs.value.length - 1) index = 0
if (isTabs) {
const tab = tabs.value[index]
router.replace({
...route,
query: {
...route.query,
[props.tabQueryField]:
tab && 'name' in tab
? tab.name
: index
}
})
} else {
const targetTab = tabs.value[index];
if (targetTab && targetTab.to) {
router.replace(targetTab.to);
}
}
nextTick(() => {
if (typeof window === 'undefined') return // SSR early return
document.getElementById(id + index + 'tab')?.focus()
})
}
const computedTabs = computed(() => tabs.value.map((tab, index) => ({
...tab,
'key': index,
'id': id + index + 'tab',
'aria-selected': currentIndex.value === index ? 'true' : 'false',
'tabindex': index === currentIndex.value ? '0' : '-1',
'onKeydown': (e: KeyboardEvent) => {
if (['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'].includes(e.key))
e.preventDefault()
if (['ArrowRight', 'ArrowDown'].includes(e.key))
return navigateTo(index + 1)
if (['ArrowLeft', 'ArrowUp'].includes(e.key))
return navigateTo(index - 1)
if (['Home'].includes(e.key))
return navigateTo(0)
if (['End'].includes(e.key))
return navigateTo(Number.MAX_SAFE_INTEGER)
},
...(isTabs ? ({
'role': 'tab',
'aria-controls': id + index + 'tabpanel',
'aria-posinset': index + 1,
'aria-setsize': tabs.value.length,
'aria-pressed': undefined,
'_____myIndex': index,
'_____currentIndex': currentIndex.value,
'onClick': () => {
navigateTo(index)
}
}) : 'to' in tab && tab.to ? to(tab.to) : ({}))
} as const)
))
const tabpanels = computed(() => tabs.value.map((_, index) => ({
'aria-labelledby': id + index + 'tab',
'role': 'tabpanel',
'id': id + index + 'tabpanel',
'key': id + index,
'isActive': currentIndex.value === index
} as const)
))
</script>
<template>
<Layout
:id
nav
flex
:role="isTabs ? 'tablist' : undefined"
:class="$style.tablist"
>
<component
:is="isTabs ? Button : Link"
v-for="{ badge, ...tab } in computedTabs"
v-bind="tab"
:key="tab.key"
class="color-default-ghost shape-field-minimal"
ui-block-size="40"
ui-no-underline
>
<Layout
stack
no-gap
>
<span
:class="$style.falseTitle"
aria-hidden="true"
>{{ tab.title }}</span>
<span :class="$style.realTitle">{{ tab.title }}</span>
</Layout>
<Badge v-if="badge">
{{ badge }}
</Badge>
</component>
</Layout>
<slot
:current-index
:tabpanels
/>
</template>
<style module>
.tablist {
.realTitle {
font-size: 16px;
font-weight: 400;
}
.falseTitle[aria-hidden="true"] {
font-size: 16px;
font-weight: 900;
opacity: 0;
pointer-events: none;
max-height: 0;
overflow: hidden;
}
/* TODO: Add this variant to Button.vue and Link.vue */
button, a {
color: var(--color-700);
background: transparent;
padding: 0;
&:hover{
color: var(--color-950);
}
}
:is([aria-selected="true"], [aria-current]:not([aria-current="false"])){
.realTitle {
font-weight: 900;
&:after {
content: '';
display: block;
height: 4px;
background-color: var(--fw-orange-500);
margin: 0 auto;
width: calc(10% + 2rem);
position: absolute;
inset: auto 0 -2px 0;
border-radius: 100vh;
}
}
}
}
</style>Choose a unique tabQueryField to enable tabbing functionality. It allows users to reload or share the page without losing the active tab state. See the example below to learn how to make your tabbed interfaces accessible.
If you do not include the tabQueryField, you are limited to one Nav per page, and changing to another tab will reload the whole page.
Adding multiple tab regions to one page
If you add several tabbed regions into your page, make sure they have distinct queryField values so that they operate independently. In some cases you may want to use the same queryField, for example in a documentation where users can switch between yarn and npm and you have several code blocks that would all switch between the two options in a synchronized way.
You can even persist these choices across pages by telling Vue RouterLinks to preserve the corresponding route parameter.
A badge strongly urges the user to take a destructive or maintenance action. Only use when all of the following criteria are met:
Typical example include:
By default, the URL stores the active tab as a number (?tab=0). Add name: 'a' to the first tab to represent it with a unique name (?tab=a). If you use unsafe letters, they will automatically be url-encoded and may look confusing, so best stick to ASCII. Spaces are represented as +. See the Tab C definition in the next section for an example of using name.
Using this component
const tabs = [{
title: 'Tab A',
badge: 'secondary.raised.primary 1'
}, {
title: 'Tab B'
}, {
title: 'Tab C',
badge: 3,
name: 'final ö $%&/()=?'
}]Note that we are using :model-value instead of v-model to assign the immutable (const) tabs model.
<Nav
v-slot="{ tabpanels }"
:model-value="tabs"
tab-query-field="tab"
>
<div
v-for="({ isActive, ...panel }, index) in tabpanels"
v-show="isActive"
v-bind="panel"
:key="panel.key"
:class="`solid ${['green', 'blue', 'purple'][index]}`"
>
Tab content {{ ['1 (green)', '2 (blue)', '3 (purple, named)'][index] }}
<hr>
<Switch
v-model="isOn[index]"
:label="`Switch ${index}`"
/>
</div>
</Nav>