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.
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
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).
<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>
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | TabItem[] | Overview, Activity, Members, Settings, Archived | Tabs with id, label and optional count, disabled flag and plain-text content. |
| modelValue | string | undefined | Id of the selected tab (v-model). Falls back to the first enabled tab. |
| variant | 'underline' | 'pills' | 'underline' | Visual style of the tab list. |
| label | string | '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. |
| Event | Payload | Description |
|---|---|---|
| update:modelValue | (id: string) | Fired with the new tab id, for v-model. |
| change | (tab: TabItem) | Fired when the selected tab changes. |
| Slot | Description |
|---|---|
| [tab.id] | One slot per tab, named after its id, receiving { tab }. Falls back to tab.content. |
Component · Navigation
A ⌘K / Ctrl+K command palette for Vue 3 with fuzzy search, grouped results, recent items and full keyboard navigation. Tailwind CSS, dark mode.
Component · Navigation
Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.
Component · Accordions & FAQs
Accessible Vue 3 FAQ accordion with single or multiple open answers, smooth height animation and full ARIA wiring. Tailwind CSS, dark mode.
Component · Navbars
Vue 3 navbar with Products and Resources mega-menu panels, keyboard navigation and a mobile accordion. Built with Tailwind CSS, dark mode ready.