FAQ
Questions and answers in two columns: the heading on the left, held in place while the list scrolls; the questions on the right.
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
import { SPRING, Spring, createLoop, onReducedMotionChange, prefersReducedMotion } from '../shared/spring';
import { resolveAccent, type Accent } from '../shared/accent';
import { clamp, cx } from '../blocks-kit/kit';
import '../shared/ember.css';
import './faq.css';
export interface FaqItem {
question: string;
answer: ReactNode;
}
export interface FaqProps {
title?: ReactNode;
text?: ReactNode;
/** Under the heading: a contact line, a link. */
aside?: ReactNode;
items: readonly FaqItem[];
/** single: opening one closes the others. multiple: each opens on its own. */
type?: 'single' | 'multiple';
/** Indices open at first. */
defaultOpen?: readonly number[];
onOpenChange?: (open: number[]) => void;
accent?: Accent;
className?: string;
style?: CSSProperties;
}
interface Sim {
/** Openness 0..1. */
p: Spring;
full: number;
}
/**
* Questions and answers in two columns: the heading on the left, held in place while the list
* scrolls; the questions on the right. A question opens on a spring, its answer rising in as the
* panel grows.
*/
export function Faq({
title,
text,
aside,
items,
type = 'multiple',
defaultOpen = [],
onOpenChange,
accent: accentProp,
className,
style,
}: FaqProps) {
const accent = resolveAccent(accentProp);
const uid = useId().replace(/[^a-zA-Z0-9-]/g, '');
const [open, setOpen] = useState<number[]>(() => {
const list = defaultOpen.filter((i) => i >= 0 && i < items.length);
return type === 'single' ? list.slice(0, 1) : [...list];
});
const headers = useRef<(HTMLButtonElement | null)[]>([]);
const panels = useRef<(HTMLDivElement | null)[]>([]);
const contents = useRef<(HTMLDivElement | null)[]>([]);
const icons = useRef<(SVGPathElement | null)[]>([]);
const sims = useRef<Sim[]>([]);
const openRef = useRef(new Set(open));
const loopRef = useRef<ReturnType<typeof createLoop> | null>(null);
const reducedRef = useRef(false);
while (sims.current.length < items.length) {
const i = sims.current.length;
const on = open.includes(i);
sims.current.push({ p: new Spring(on ? 1 : 0, SPRING.soft), full: 0 });
}
const paint = () => {
sims.current.forEach((s, i) => {
const panel = panels.current[i];
const content = contents.current[i];
if (!panel || !content) return;
const p = clamp(s.p.value, 0, 1.2);
const isOpen = openRef.current.has(i);
const settled = Math.abs(s.p.velocity) < 0.001 && Math.abs(s.p.value - s.p.target) < 0.001;
panel.hidden = !isOpen && settled && s.p.value <= 0.001;
// Settled open: let the panel size itself, so a reflow never clips the answer.
panel.style.height = isOpen && settled ? 'auto' : `${(Math.max(0, s.p.value) * s.full).toFixed(2)}px`;
const q = clamp(p, 0, 1);
content.style.opacity = clamp(q * 1.4 - 0.25, 0, 1).toFixed(3);
content.style.transform = q >= 1 ? '' : `translate3d(0, ${((1 - q) * 8).toFixed(2)}px, 0)`;
const icon = icons.current[i];
if (icon) icon.style.transform = `rotate(${(q * 90).toFixed(2)}deg)`;
});
};
useLayoutEffect(() => {
sims.current.forEach((s, i) => {
s.full = contents.current[i]?.offsetHeight ?? 0;
});
paint();
reducedRef.current = prefersReducedMotion();
const loop = createLoop((dt) => {
let moving = false;
for (const s of sims.current) if (s.p.step(dt)) moving = true;
paint();
return moving;
});
loopRef.current = loop;
const off = onReducedMotionChange((r) => {
reducedRef.current = r;
});
return () => {
loop.stop();
off();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const commit = (next: number[]) => {
const before = openRef.current;
openRef.current = new Set(next);
sims.current.forEach((s, i) => {
const on = openRef.current.has(i);
if (on && !before.has(i)) {
// Measure the answer at its natural height before animating to it.
const panel = panels.current[i];
if (panel) panel.hidden = false;
s.full = contents.current[i]?.offsetHeight ?? s.full;
} else if (!on && before.has(i)) {
// Closing from 'auto': start from the height it has now.
s.full = contents.current[i]?.offsetHeight ?? s.full;
}
s.p.target = on ? 1 : 0;
if (reducedRef.current) s.p.set(s.p.target);
});
paint();
loopRef.current?.wake();
setOpen(next);
onOpenChange?.(next);
};
const toggle = (i: number) => {
const isOpen = openRef.current.has(i);
if (type === 'single') commit(isOpen ? [] : [i]);
else commit(isOpen ? [...openRef.current].filter((j) => j !== i) : [...openRef.current, i].sort((a, b) => a - b));
};
const onKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
const n = items.length;
const go = (j: number) => {
e.preventDefault();
headers.current[(j + n) % n]?.focus();
};
if (e.key === 'ArrowDown') go(i + 1);
else if (e.key === 'ArrowUp') go(i - 1);
else if (e.key === 'Home') go(0);
else if (e.key === 'End') go(n - 1);
};
// Answers can reflow when the width changes; keep the measured heights current.
useEffect(() => {
const ro = new ResizeObserver(() => {
sims.current.forEach((s, i) => {
const h = contents.current[i]?.offsetHeight;
if (h) s.full = h;
});
});
contents.current.forEach((c) => c && ro.observe(c));
return () => ro.disconnect();
}, [items.length]);
return (
<section
className={cx('ember', 'em-faq', className)}
data-accent={accent}
aria-labelledby={title != null ? `em-faq-${uid}-title` : undefined}
style={style}
>
<div className="em-faq__layout">
{title != null || text != null || aside != null ? (
<header className="em-faq__head">
{title != null ? (
<h2 id={`em-faq-${uid}-title`} className="em-faq__title">
{title}
</h2>
) : null}
{text != null ? <p className="em-faq__text">{text}</p> : null}
{aside != null ? <div className="em-faq__aside">{aside}</div> : null}
</header>
) : null}
<div className="em-faq__list">
{items.map((item, i) => {
const isOpen = open.includes(i);
const hid = `em-faq-${uid}-q-${i}`;
const pid = `em-faq-${uid}-a-${i}`;
return (
<div key={i} className="em-faq__item" data-open={isOpen ? '' : undefined}>
<h3 className="em-faq__q">
<button
ref={(el) => void (headers.current[i] = el)}
id={hid}
type="button"
className="em-faq__button"
aria-expanded={isOpen}
aria-controls={pid}
onClick={() => toggle(i)}
onKeyDown={(e) => onKey(e, i)}
>
<span className="em-faq__question">{item.question}</span>
<svg className="em-faq__icon" viewBox="0 0 12 12" aria-hidden="true">
<path d="M1.5 6h9" />
<path ref={(el) => void (icons.current[i] = el)} className="em-faq__bar" d="M6 1.5v9" />
</svg>
</button>
</h3>
<div ref={(el) => void (panels.current[i] = el)} id={pid} role="region" aria-labelledby={hid} className="em-faq__panel">
<div ref={(el) => void (contents.current[i] = el)} className="em-faq__answer">
{item.answer}
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}
export default Faq;
Customize
multiple
single
A question opens on a spring with its answer rising in as the panel grows. Opening one can close the others; arrow keys move between questions.
Install
npx shadcn@latest add https://obsydeui.com/r/faq.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { Faq } from '@/components/obsyde/ember/faq/Faq';
export function Example() {
return (
<Faq />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/faq/Faq.tsxcomponentember/faq/faq-demo.cssstylesember/faq/faq.cssstylesember/faq/FaqDemo.tsxdemoember/blocks-kit/kit.tsxsharedember/shared/accent.tssharedember/shared/spring.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
typeOpen | 'multiple' | 'single' | 'multiple' |
firstOpenFirst question open | boolean | true |