Vue componentFree

Underline Tabs

Accessible Vue 3 tabs with underline and pills variants, a sliding indicator, roving tabindex and arrow-key navigation. Tailwind CSS, v-model.

by Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

Tabs that follow the WAI-ARIA tabs pattern: a tablist of buttons with aria-selected and aria-controls, labelled tabpanels, and a roving tabindex so the whole tab list is one Tab stop. ArrowLeft and ArrowRight move between tabs, Home and End jump to the ends, and disabled tabs are skipped.

Choose the underline variant for page sections or the pills variant for segmented controls. In both, a single indicator slides to the selected tab and follows it when the layout resizes. On narrow screens the tab list scrolls horizontally and keeps the selected tab in view.

Bind the selected tab with v-model and put each panel in a slot named after the tab's id. Tabs without a slot fall back to their plain-text content, and activation can be automatic (select on focus) or manual (Enter or Space).

  • Underline and pills variants
  • Roving tabindex with arrow keys, Home and End
  • Animated sliding indicator
  • Named slot per panel, v-model support
  • Counter badges and disabled tabs

Code

11 files in the repo
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'

export interface TabItem {
  id: string
  label: string
  /** Small counter pill after the label. */
  count?: number
  disabled?: boolean
  /** Plain-text panel content, used when no slot named after the tab id is provided. */
  content?: string
}

const props = withDefaults(defineProps<{
  tabs?: TabItem[]
  /** Id of the selected tab (v-model). Defaults to the first enabled tab. */
  modelValue?: string
  variant?: 'underline' | 'pills'
  /** Accessible name of the tab list. */
  label?: string
  /** 'auto' selects on arrow key focus; 'manual' waits for Enter or Space. */
  activation?: 'auto' | 'manual'
}>(), {
  modelValue: undefined,
  variant: 'underline',
  label: 'Project sections',
  activation: 'auto',
  tabs: () => [
    { id: 'overview', label: 'Overview', content: 'A summary of the project: goals, owners and the milestones coming up in the next two weeks.' },
    { id: 'activity', label: 'Activity', count: 12, content: 'Twelve updates since your last visit, including two merged changes and a new comment on the launch plan.' },
    { id: 'members', label: 'Members', count: 8, content: 'Eight people have access. Three are editors, five can view and comment.' },
    { id: 'settings', label: 'Settings', content: 'Rename the project, change its visibility or archive it when the work is done.' },
    { id: 'archived', label: 'Archived', disabled: true },
  ],
})

const emit = defineEmits<{
  'update:modelValue': [id: string]
  change: [tab: TabItem]
}>()

const uid = useId()
const firstEnabled = computed(() => props.tabs.find(t => !t.disabled)?.id ?? '')
const internal = ref(props.modelValue ?? firstEnabled.value)
watch(() => props.modelValue, (v) => { if (v !== undefined) internal.value = v })
const selected = computed(() => internal.value)

const listRef = ref<HTMLElement | null>(null)
const tabRefs = ref<HTMLButtonElement[]>([])
const indicator = ref({ left: 0, width: 0, top: 0, height: 0, ready: false })

function select(tab: TabItem) {
  if (tab.disabled || tab.id === internal.value) return
  internal.value = tab.id
  emit('update:modelValue', tab.id)
  emit('change', tab)
}

function tabEl(id: string) {
  return tabRefs.value.find(el => el.dataset.id === id)
}

function onKeydown(e: KeyboardEvent, index: number) {
  const enabled = props.tabs.map((t, i) => ({ t, i })).filter(x => !x.t.disabled)
  const pos = enabled.findIndex(x => x.i === index)
  let target: TabItem | undefined
  if (e.key === 'ArrowRight') target = enabled[(pos + 1) % enabled.length]?.t
  else if (e.key === 'ArrowLeft') target = enabled[(pos - 1 + enabled.length) % enabled.length]?.t
  else if (e.key === 'Home') target = enabled[0]?.t
  else if (e.key === 'End') target = enabled[enabled.length - 1]?.t
  else if ((e.key === 'Enter' || e.key === ' ') && props.activation === 'manual') {
    e.preventDefault()
    select(props.tabs[index]!)
    return
  }
  if (!target) return
  e.preventDefault()
  tabEl(target.id)?.focus()
  if (props.activation === 'auto') select(target)
}

// Slide a single indicator (underline or pill background) under the selected tab.
function measure() {
  const el = tabEl(selected.value)
  if (!el) return
  indicator.value = { left: el.offsetLeft, width: el.offsetWidth, top: el.offsetTop, height: el.offsetHeight, ready: true }
  if (listRef.value && (el.offsetLeft < listRef.value.scrollLeft || el.offsetLeft + el.offsetWidth > listRef.value.scrollLeft + listRef.value.clientWidth)) {
    el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
  }
}
watch([selected, () => props.variant, () => props.tabs], () => nextTick(measure))

let ro: ResizeObserver | undefined
onMounted(() => {
  measure()
  ro = new ResizeObserver(measure)
  if (listRef.value) ro.observe(listRef.value)
})
onBeforeUnmount(() => ro?.disconnect())

const pills = computed(() => props.variant === 'pills')
</script>

<template>
  <div>
    <div :class="pills ? '' : 'border-b border-gray-200 dark:border-white/10'">
      <div
        ref="listRef"
        role="tablist"
        :aria-label="label"
        class="relative -mb-px flex overflow-x-auto [scrollbar-width:none]"
        :class="pills ? 'w-fit max-w-full gap-1 rounded-xl bg-gray-100 p-1 dark:bg-white/5' : 'gap-6'"
      >
        <span
          aria-hidden="true"
          class="pointer-events-none absolute transition-all duration-300 ease-out motion-reduce:transition-none"
          :class="[
            pills ? 'rounded-lg bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-fuchsia-500/15 dark:shadow-none dark:ring-fuchsia-400/25' : 'bottom-0 h-0.5 rounded-full bg-fuchsia-600 dark:bg-fuchsia-400',
            indicator.ready ? 'opacity-100' : 'opacity-0',
          ]"
          :style="pills
            ? { left: `${indicator.left}px`, width: `${indicator.width}px`, top: `${indicator.top}px`, height: `${indicator.height}px` }
            : { left: `${indicator.left}px`, width: `${indicator.width}px` }"
        />
        <button
          v-for="(tab, index) in tabs"
          :id="`${uid}-tab-${tab.id}`"
          :key="tab.id"
          ref="tabRefs"
          type="button"
          role="tab"
          :data-id="tab.id"
          :aria-selected="tab.id === selected"
          :aria-controls="`${uid}-panel-${tab.id}`"
          :tabindex="tab.id === selected ? 0 : -1"
          :disabled="tab.disabled"
          class="relative z-10 inline-flex shrink-0 items-center gap-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-fuchsia-600 disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:outline-fuchsia-400"
          :class="[
            pills ? 'rounded-lg px-3.5 py-2 focus-visible:outline-offset-0' : 'rounded-t-md px-1 pt-2 pb-3.5 focus-visible:-outline-offset-2',
            tab.id === selected
              ? (pills ? 'text-gray-900 dark:text-white' : 'text-fuchsia-700 dark:text-fuchsia-300')
              : 'text-gray-500 enabled:hover:text-gray-800 dark:text-gray-400 dark:enabled:hover:text-gray-200',
          ]"
          @click="select(tab)"
          @keydown="onKeydown($event, index)"
        >
          {{ tab.label }}
          <span
            v-if="tab.count !== undefined"
            class="rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
            :class="tab.id === selected
              ? 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-400/20 dark:text-fuchsia-200'
              : 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300'"
          >{{ tab.count }}</span>
        </button>
      </div>
    </div>

    <div
      v-for="tab in tabs"
      v-show="tab.id === selected"
      :id="`${uid}-panel-${tab.id}`"
      :key="tab.id"
      role="tabpanel"
      :aria-labelledby="`${uid}-tab-${tab.id}`"
      tabindex="0"
      class="mt-6 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-fuchsia-600 dark:focus-visible:outline-fuchsia-400"
    >
      <slot :name="tab.id" :tab="tab">
        <p class="text-sm/6 text-gray-600 dark:text-gray-400">{{ tab.content }}</p>
      </slot>
    </div>
  </div>
</template>

Props

PropTypeDefaultDescription
tabsTabItem[]Overview, Activity, Members, Settings, ArchivedTabs with id, label and optional count, disabled flag and plain-text content.
modelValuestringundefinedId of the selected tab (v-model). Falls back to the first enabled tab.
variant'underline' | 'pills''underline'Visual style of the tab list.
labelstring'Project sections'Accessible name of the tab list.
activation'auto' | 'manual''auto''auto' selects a tab when arrow keys focus it; 'manual' waits for Enter or Space.

Events

EventPayloadDescription
update:modelValue(id: string)Fired with the new tab id, for v-model.
change(tab: TabItem)Fired when the selected tab changes.

Slots

SlotDescription
[tab.id]One slot per tab, named after its id, receiving { tab }. Falls back to tab.content.
All tabs →
Preview of Command Palette$9

Component · Navigation

Command Palette

A ⌘K / Ctrl+K command palette for Vue 3 with fuzzy search, grouped results, recent items and full keyboard navigation. Tailwind CSS, dark mode.

by Vueplay
Preview of Dropdown MenuFree

Component · Navigation

Dropdown Menu

Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.

by Vueplay
Preview of FAQ AccordionFree

Component · Accordions & FAQs

FAQ Accordion

Accessible Vue 3 FAQ accordion with single or multiple open answers, smooth height animation and full ARIA wiring. Tailwind CSS, dark mode.

by Vueplay
Preview of Mega Menu NavbarFree

Component · Navbars

Mega Menu Navbar

Vue 3 navbar with Products and Resources mega-menu panels, keyboard navigation and a mobile accordion. Built with Tailwind CSS, dark mode ready.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings