Skip to content
ts
import Nav from '~/ui/Nav.vue'

Nav

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.

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

Tabs

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:

  1. A maintenance problem calls for immediate action
  2. The button or link is the fastest way to get there
  3. The user needs to take a maintenance action to make the badge disappear

Typical example include:

  • A message inboxes where the primary feature is reading and archiving messages (in Funkwhale: User messages)
  • A moderation feature where reports need to be answered

Name a tab

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

A11y Checklist

Accessible Nav

Tab content 1 (green)
ts
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.

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

Test this component in isolation against WCAG2 criteria