Frequently asked questions
Overview
The FAQ block is two pieces: a wrapper <Faq> that owns the title and the surface (or no surface, in flush mode), and any number of <FaqItem> children that each render a question / answer pair. The component deliberately accepts arbitrary JSX for the answer — links, lists, formulas, illustrations — instead of forcing a string.
Disclosure, not accordion
single for accordion behaviour where exactly one item can be open at a time. Either way, the underlying ARIA stays a disclosure pattern — predictable for screen readers regardless of the coordination mode.Variants
Surface is the default — a contained block that reads as its own unit. Flush drops the background and padding so the FAQ becomes part of the surrounding content flow.
Surface
Flush
Single vs. multi
Single-open keeps the page concise — only one answer is on screen at a time. Multi-open is the right default for browsing FAQs where users compare answers side-by-side. Pick based on whether the user is reading or scanning.
Single-open
Multi-open
Rich answers
Answers are children, not strings. Drop in lists, links, formulas, or any other markup the answer needs. Just keep them short — the FAQ row should reveal one paragraph or one short list, not an essay. Long answers belong on a dedicated page.
Answers can be anything you can render
- Open the portal and choose Capital from the side rail.
- Pick the offer that matches the runway you want.
- Sign the digital agreement — repayment starts the next morning.
Anatomy
Three parts. The block surface holds the heading and the list; each row holds the question and the icon; opening the row reveals the answer panel directly beneath.
FAQ
Question
Inter Tight Medium 16/24, full primary text colour. Buttons get the focus ring; the row itself has no hover background — restraint over chrome.
Hairline divider
1 px border at the bottom of every row except the last. When a row is open, the divider sits below the answer panel so it stays anchored to the row it closes.
Plus / minus icon
Two-line SVG. The vertical bar rotates 90° to overlap the horizontal — so the plus collapses to a minus with a single GPU-friendly transform. No icon swap, no flicker.
Answer panel
Hidden by default. Mounts always (so screen readers can follow the disclosure cycle); height animates from
grid-template-rows: 0frto1frwith a 220 ms ease-out-quart. No JS measurement, no layout flicker.Surface
Light-grey card with rounded corners and breathing-room padding. Drop the surface entirely with variant="flush" when the FAQ should read as part of its surrounding content.
Behavior
- Click toggles. Tapping or pressing Enter / Space on a question expands its panel; another press collapses it. There is no separate “close” affordance.
- Single-open is opt-in. Default behaviour is multi-open. Pass
singleto make opening one item collapse the others — accordion semantics without the loaded word. - The plus rotates, it doesn't swap. The icon is two SVG lines: the horizontal stays, the vertical rotates 90° to overlap it. A pure CSS transform means no flicker and no second icon swapping in.
- Height animates without measuring. We animate
grid-template-rowsfrom0frto1frwithoverflow: hiddenon the inner cell. The browser interpolates the implicit height for us — no measurement, no layout flicker on first paint. - Reduced motion gets static toggles. Under
prefers-reduced-motion, the height transition and the plus rotation skip — the panel snaps open or closed and the icon swap is instant.
Accessibility
The FAQ block uses the disclosure pattern. Each question is a <button> with aria-expanded and aria-controls; the panel is a role="region" with aria-labelledby pointing at the button. The wrapper, when given a string title, becomes a labelled <section> so screen readers can navigate to it as a landmark.
- TabMove focus between questions in source order.
- Enter / SpaceToggle the focused question. Double-press collapses again.
- aria-expandedTrue/false on each button — screen readers announce “expanded” or “collapsed” directly.
- aria-hiddenThe panel is hidden from the a11y tree when collapsed so closed answers aren't announced.
Code
Two patterns: the typical multi-open FAQ on a marketing page, and a single-open variant deep-linked via URL hash.
Multi-open
tsx
import { Faq, FaqItem } from "@flatpay-dk/ui";
function CapitalFaq() {
return (
<Faq title="Frequently asked questions">
<FaqItem question="How do I pay my cash advance?">
A fixed percentage of your daily card sales is automatically deducted
until your cash advance is fully repaid…
</FaqItem>
<FaqItem question="Can I request a different cash advance amount?">
Offers are calibrated to your typical card-sales volume…
</FaqItem>
<FaqItem question="What happens if I don't make any sales?">
That day's repayment is zero — repayment scales with revenue…
</FaqItem>
</Faq>
);
}Single-open with deep link
tsx
"use client";
import * as React from "react";
import { Faq, FaqItem } from "@flatpay-dk/ui";
function HelpCentreFaq() {
// Read the URL hash so /help#refunds opens the matching item.
const [openId, setOpenId] = React.useState<string | null>(null);
React.useEffect(() => {
const fromHash = window.location.hash.slice(1);
if (fromHash) setOpenId(fromHash);
}, []);
return (
<Faq
title="Help centre"
single
openId={openId}
onOpenChange={(next) => {
setOpenId(next);
// Update the URL without reloading so the answer is shareable.
if (next) history.replaceState(null, "", `#${next}`);
else history.replaceState(null, "", window.location.pathname);
}}
>
<FaqItem id="refunds" question="How do I issue a refund?">…</FaqItem>
<FaqItem id="payouts" question="When do payouts arrive?">…</FaqItem>
<FaqItem id="hardware" question="My terminal won't connect.">…</FaqItem>
</Faq>
);
}Best practices
Do
Keep questions short and concrete. Lead with the verb the user is trying to do.
Don't
Don't pose questions in the brand voice or use FAQ as marketing ('What makes us different?'). Users skip these.
Do
Cap an FAQ at ~6 items. Anything more belongs on a dedicated help page with search.
Don't
Don't paste 30 questions into one block. The disclosure pattern stops scaling past a screen of rows.
Props
Faq
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | — | Heading rendered above the list. Strings get an h3 with the right type styles; pass your own heading element to set a different level. |
| description | ReactNode | — | Optional sub-heading paragraph between title and the first row. |
| variant | "surface" | "flush" | "surface" | surface = padded card with rounded corners; flush = no background, no padding. |
| single | boolean | false | When true, only one item can be open at a time — accordion semantics. |
| defaultOpenId | string | — | Single mode only. The FaqItem id that should be open on first render. |
| openId | string | null | — | Controlled open id. Pair with onOpenChange. Single mode only. |
| onOpenChange | (next: string | null) => void | — | Fires when the open item changes. Single mode only. |
FaqItem
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Stable id used for ARIA wiring and single-open coordination. Auto-generated if omitted. |
| question* | ReactNode | — | The trigger label. |
| defaultOpen | boolean | false | Open by default in non-single, uncontrolled mode. Ignored when wrapped in <Faq single>. |
| open | boolean | — | Controlled open state. Wins over defaultOpen and the single-mode context. |
| onOpenChange | (open: boolean) => void | — | Fires whenever this item's open state changes. |
| children* | ReactNode | — | The answer body. Any JSX — keep it short, prefer one paragraph or one short list. |