Skip to content
Velocity X Docs
← All components

Accordion / FAQ List

Free · MIT

Expand/collapse FAQ list with measured-height animation, single or multi-open modes, and arrow-key navigation between headers.

Category
Core UI
Framework
react
Version
1.0.0
Peer deps
react

Install

bash
velocityx add accordion-faq

Free and MIT licensed — no account needed. The file lands under src/components/velocity/accordion-faq/ by default (override with --dest), and it's yours to edit from the moment it's written.

Demo

View the live demo on the showcase site ↗

Source

Pulled live from the registry — this is the exact file velocityx add accordion-faq writes into your project right now.

components/velocity/accordion-faq/Accordion.jsx

tsx
/*
 *    VELOCITY COMPONENT · theming
 *
 *    Colours here are Tailwind `primary-*` / `accent-*`, which resolve through
 *    the Velocity preset. Out of the box they are a neutral indigo — NOT the
 *    Aidxn brand — so this drops into your project without dragging our look in.
 *
 *    ⚡ NOT USING VELOCITY? Run this once, in your project root:
 *
 *          velocityx theme
 *
 *    It writes velocity-preset.cjs + velocity-theme.css. Add the preset to your
 *    tailwind.config, import the css, then set your whole brand in ONE place:
 *
 *          :root {
 *            --vx-brand: custom;
 *            --vx-primary-600: 220 38 38;   <- your action colour
 *            --vx-primary-700: 185 28 28;
 *          }
 *
 *    ⚠️  Raw RGB channels, space separated — NOT hex. `#DC2626` here silently
 *        breaks every /opacity variant in the library (bg-primary-600/20 etc).
 *
 *    Prefer not to install anything? Find-and-replace `primary-` with your own
 *    scale name. Nothing else in this file depends on Velocity.
 */
/**
 * Accordion — FAQ-style expand/collapse list.
 *
 * Animates height via measured scrollHeight (no CSS-grid-trick dependency on
 * browser quirks, no external lib). Single-open ("allowMultiple=false",
 * default) or multi-open. Full keyboard support: Enter/Space toggles,
 * Up/Down/Home/End move focus between headers.
 *
 * Props:
 *   items          { id, question, answer: ReactNode }[]
 *   allowMultiple  boolean (default false)
 *   defaultOpenIds string[]
 */
import React, { useRef, useState } from 'react';

function Panel({ id, open, children }) {
  const ref = useRef(null);
  return (
    <div
      id={`faq-panel-${id}`}
      role="region"
      aria-labelledby={`faq-header-${id}`}
      ref={ref}
      style={{ maxHeight: open ? ref.current?.scrollHeight ?? 1000 : 0 }}
      className="overflow-hidden transition-[max-height] duration-300 motion-reduce:transition-none"
    >
      <div className="px-5 pb-4 pt-0 body-md text-secondary-600 dark:text-secondary-300">{children}</div>
    </div>
  );
}

export default function Accordion({ items = [], allowMultiple = false, defaultOpenIds = [] }) {
  const [openIds, setOpenIds] = useState(new Set(defaultOpenIds));
  const headerRefs = useRef([]);

  const toggle = (id) => {
    setOpenIds((prev) => {
      const next = new Set(allowMultiple ? prev : []);
      if (prev.has(id) && allowMultiple) {
        next.delete(id);
      } else if (!prev.has(id)) {
        next.add(id);
      }
      return next;
    });
  };

  const onKeyDown = (e, idx) => {
    let target = null;
    if (e.key === 'ArrowDown') target = idx + 1;
    if (e.key === 'ArrowUp') target = idx - 1;
    if (e.key === 'Home') target = 0;
    if (e.key === 'End') target = items.length - 1;
    if (target !== null) {
      e.preventDefault();
      const clamped = (target + items.length) % items.length;
      headerRefs.current[clamped]?.focus();
    }
  };

  return (
    <div className="divide-y divide-secondary-200 dark:divide-secondary-800 rounded-2xl border border-secondary-200 dark:border-secondary-800 bg-white dark:bg-secondary-900">
      {items.map((item, idx) => {
        const open = openIds.has(item.id);
        return (
          <div key={item.id}>
            <h3>
              <button
                ref={(el) => (headerRefs.current[idx] = el)}
                id={`faq-header-${item.id}`}
                type="button"
                aria-expanded={open}
                aria-controls={`faq-panel-${item.id}`}
                onClick={() => toggle(item.id)}
                onKeyDown={(e) => onKeyDown(e, idx)}
                className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-inset"
              >
                <span className="v-card-title text-secondary-900 dark:text-white">{item.question}</span>
                <svg
                  aria-hidden="true"
                  width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
                  className={`shrink-0 text-primary-600 dark:text-primary-400 transition-transform duration-200 motion-reduce:transition-none ${open ? 'rotate-45' : ''}`}
                >
                  <path d="M12 5v14" /><path d="M5 12h14" />
                </svg>
              </button>
            </h3>
            <Panel id={item.id} open={open}>{item.answer}</Panel>
          </div>
        );
      })}
    </div>
  );
}

Theming

Uses the shared primary-* / accent-* scale — see Theming if colours look unstyled after install.