Vue componentFree

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 Vueplayv1.0.0Updated 2 hours ago

Install
Full screen ↗

About this component

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.

  • Single or multiple open mode
  • Height animation without JavaScript measuring
  • aria-expanded, aria-controls and inert panels
  • Arrow key, Home and End navigation
  • Light and dark mode

Code

11 files in the repo
<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>

Props

PropTypeDefaultDescription
itemsFaqItem[]6 sample questionsQuestions and answers ({ question, answer }).
headingstring'Frequently asked questions'Section heading.
subheadingstring'Everything you need to know…'Text under the heading.
multiplebooleanfalseAllow several answers open at once. When false, opening one closes the others.
defaultOpennumber[][0]Indexes of answers open on first render (only the first is used when multiple is false).

Events

EventPayloadDescription
toggle(index: number, open: boolean)Fired when a question is opened or closed by the user.

Slots

SlotDescription
answerScoped slot ({ item, index }) to render a rich answer instead of plain text.
asideContent under the heading, e.g. a contact card.
All accordions & faqs →
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
Preview of Responsive NavbarFree

Component · Navbars

Responsive Navbar

Responsive Vue 3 navbar with logo, links, a CTA and an accessible hamburger menu that closes on Escape. Tailwind CSS, dark mode, typed props.

by Vueplay
Preview of Testimonial CarouselFree

Component · Testimonials

Testimonial Carousel

Accessible Vue 3 testimonial carousel with prev/next, dots and autoplay that pauses on hover or focus and honours reduced motion. Tailwind CSS.

by Vueplay
Preview of Underline TabsFree

Component · Tabs

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