Accordion / FAQ List
Free · MITExpand/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
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
/*
* 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.