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.
Accessible Vue 3 FAQ accordion with single or multiple open answers, smooth height animation and full ARIA wiring. Tailwind CSS, dark mode.
by Vueplayv1.0.0Updated 2 hours ago
A frequently asked questions section with the heading on the left and the accordion on the right, stacking on small screens. Each question is a button inside a heading, so screen reader users can jump between questions with heading navigation.
Buttons carry aria-expanded and aria-controls, answers are labelled regions, and closed answers are inert so their links can't be tabbed into. ArrowUp, ArrowDown, Home and End move between questions. The open and close animation uses the grid-template-rows 0fr to 1fr technique, so it animates to the natural height without measuring anything and switches off under reduced motion.
Set multiple to let several answers stay open, pick which start open with defaultOpen, and use the answer slot for rich answers with links or lists. The aside slot sits under the heading, handy for a contact card.
<script setup lang="ts">
import { ref, useId, watch } from 'vue'
export interface FaqItem {
question: string
answer: string
}
const props = withDefaults(defineProps<{
items?: FaqItem[]
heading?: string
subheading?: string
/** Allow several answers to be open at once. When false, opening one closes the others. */
multiple?: boolean
/** Indexes of items open on first render. */
defaultOpen?: number[]
}>(), {
heading: 'Frequently asked questions',
subheading: 'Everything you need to know about billing, data and getting your team started. Can\'t find an answer? Our support team replies within a few hours.',
multiple: false,
defaultOpen: () => [0],
items: () => [
{ question: 'Is there a free trial?', answer: 'Yes. Every paid plan starts with a 14-day trial with all features unlocked. We don\'t ask for a card up front, and if you don\'t upgrade your workspace simply moves to the free plan.' },
{ question: 'Can I change plans later?', answer: 'Any time, from the billing page. Upgrades apply immediately and are prorated to the day. Downgrades take effect at the end of your current billing period so you never lose paid time.' },
{ question: 'How does per-seat pricing work?', answer: 'You pay for members who can edit. Guests and viewers are always free, so you can share boards with clients and stakeholders without buying extra seats.' },
{ question: 'Where is my data stored?', answer: 'In the EU by default, in two data centres in Frankfurt and Dublin. Enterprise workspaces can choose US hosting. All data is encrypted in transit and at rest, and backups are kept for 30 days.' },
{ question: 'Can I import from another tool?', answer: 'The importer handles CSV files and most popular project tools. It maps statuses, assignees and due dates for you, and you can preview the result before anything is created.' },
{ question: 'What happens if I cancel?', answer: 'Your workspace stays readable for 90 days and you can export everything as CSV or JSON at any point. After that it is permanently deleted, along with all backups.' },
],
})
const emit = defineEmits<{
toggle: [index: number, open: boolean]
}>()
const uid = useId()
const open = ref<Set<number>>(new Set(props.multiple ? props.defaultOpen : props.defaultOpen.slice(0, 1)))
const buttons = ref<HTMLButtonElement[]>([])
// Switching to single mode keeps only the first open answer.
watch(() => props.multiple, (multiple) => {
if (!multiple && open.value.size > 1) open.value = new Set([Math.min(...open.value)])
})
function toggle(index: number) {
const next = new Set(props.multiple ? open.value : [])
const willOpen = !open.value.has(index)
if (willOpen) next.add(index)
else next.delete(index)
open.value = next
emit('toggle', index, willOpen)
}
// Optional accordion keyboard pattern: arrows and Home/End move between headers.
function onKeydown(e: KeyboardEvent, index: number) {
const last = props.items.length - 1
const target = e.key === 'ArrowDown' ? (index === last ? 0 : index + 1)
: e.key === 'ArrowUp' ? (index === 0 ? last : index - 1)
: e.key === 'Home' ? 0
: e.key === 'End' ? last
: -1
if (target < 0) return
e.preventDefault()
buttons.value.find(b => b.dataset.index === String(target))?.focus()
}
</script>
<template>
<section class="px-4 py-20 sm:px-6 sm:py-24 lg:px-8">
<div class="mx-auto grid max-w-6xl gap-12 lg:grid-cols-[minmax(0,22rem)_1fr] lg:gap-16">
<div>
<h2 class="text-3xl font-bold tracking-tight text-balance text-gray-900 sm:text-4xl dark:text-white">{{ heading }}</h2>
<p class="mt-4 text-base/7 text-pretty text-gray-600 dark:text-gray-400">{{ subheading }}</p>
<slot name="aside" />
</div>
<div class="divide-y divide-gray-200 border-y border-gray-200 dark:divide-white/10 dark:border-white/10">
<div v-for="(item, index) in items" :key="item.question">
<h3>
<button
:id="`${uid}-q-${index}`"
ref="buttons"
type="button"
:data-index="index"
:aria-expanded="open.has(index)"
:aria-controls="`${uid}-a-${index}`"
class="group flex w-full items-start justify-between gap-6 rounded-lg py-5 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-400"
@click="toggle(index)"
@keydown="onKeydown($event, index)"
>
<span
class="text-base font-semibold transition-colors"
:class="open.has(index) ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-900 group-hover:text-indigo-700 dark:text-white dark:group-hover:text-indigo-300'"
>{{ item.question }}</span>
<span
class="mt-0.5 grid size-6 shrink-0 place-items-center rounded-full ring-1 transition motion-reduce:transition-none"
:class="open.has(index)
? 'rotate-45 bg-indigo-600 text-white ring-indigo-600 dark:bg-indigo-500 dark:ring-indigo-500'
: 'text-gray-500 ring-gray-300 group-hover:text-indigo-600 group-hover:ring-indigo-300 dark:text-gray-400 dark:ring-white/20'"
aria-hidden="true"
>
<svg viewBox="0 0 16 16" class="size-3.5" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M8 3.5v9M3.5 8h9" /></svg>
</span>
</button>
</h3>
<!-- grid-rows 0fr -> 1fr animates to the content's natural height without measuring it. -->
<div
:id="`${uid}-a-${index}`"
role="region"
:aria-labelledby="`${uid}-q-${index}`"
:inert="!open.has(index)"
class="grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none"
:class="open.has(index) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="overflow-hidden">
<div class="pr-12 pb-6 text-base/7 text-gray-600 dark:text-gray-400">
<slot name="answer" :item="item" :index="index">{{ item.answer }}</slot>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| items | FaqItem[] | 6 sample questions | Questions and answers ({ question, answer }). |
| heading | string | 'Frequently asked questions' | Section heading. |
| subheading | string | 'Everything you need to know…' | Text under the heading. |
| multiple | boolean | false | Allow several answers open at once. When false, opening one closes the others. |
| defaultOpen | number[] | [0] | Indexes of answers open on first render (only the first is used when multiple is false). |
| Event | Payload | Description |
|---|---|---|
| toggle | (index: number, open: boolean) | Fired when a question is opened or closed by the user. |
| Slot | Description |
|---|---|
| answer | Scoped slot ({ item, index }) to render a rich answer instead of plain text. |
| aside | Content under the heading, e.g. a contact card. |
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.
Component · Navbars
Responsive Vue 3 navbar with logo, links, a CTA and an accessible hamburger menu that closes on Escape. Tailwind CSS, dark mode, typed props.
Component · Testimonials
Accessible Vue 3 testimonial carousel with prev/next, dots and autoplay that pauses on hover or focus and honours reduced motion. Tailwind CSS.
Component · Tabs
Accessible Vue 3 tabs with underline and pills variants, a sliding indicator, roving tabindex and arrow-key navigation. Tailwind CSS, v-model.