Components · Layout and structure

FAQ block

A contained block of questions and answers, each disclosed by a click. Editorial heading on top, hairline-separated rows below. Use it for product FAQs, capital pages, contract explainers — anywhere short questions need short, sometimes-rich answers.

Documentedby Derek Fidler

Frequently asked questions

A fixed percentage of your daily card sales is automatically deducted until your cash advance is fully repaid. If sales are up one day, you pay more; if you have a slow day, you pay less. A minimum of 1/18 of the initial balance must be repaid every 60 days.

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

The default (multi-open) behaves as a stack of independent disclosures: opening one doesn't affect the others. Pass 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

surfaceDefault. Pads the FAQ inside its own card, separates it from surrounding content.

Flush

flushNo background, no padding. Reads as part of the surrounding content.

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

Only one item at a time. Opening another closes me.
singleUse for short FAQs where a single answer is the focus.

Multi-open

By default — multiple items can stay open at once.
defaultMultiple items can be open. Better for browsing-style FAQs.

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

  1. Open the portal and choose Capital from the side rail.
  2. Pick the offer that matches the runway you want.
  3. 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

A short answer that demonstrates how the panel sits beneath the question, separated from the next row by a single hairline.
  1. 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.

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

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

  4. Answer panel

    Hidden by default. Mounts always (so screen readers can follow the disclosure cycle); height animates from grid-template-rows: 0fr to 1fr with a 220 ms ease-out-quart. No JS measurement, no layout flicker.

  5. 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 single to 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-rows from 0fr to 1fr with overflow: hidden on 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.

30+ items → use Help Centre with search

Don't

Don't paste 30 questions into one block. The disclosure pattern stops scaling past a screen of rows.

Props

Faq

PropTypeDefaultDescription
titleReactNodeHeading rendered above the list. Strings get an h3 with the right type styles; pass your own heading element to set a different level.
descriptionReactNodeOptional sub-heading paragraph between title and the first row.
variant"surface" | "flush""surface"surface = padded card with rounded corners; flush = no background, no padding.
singlebooleanfalseWhen true, only one item can be open at a time — accordion semantics.
defaultOpenIdstringSingle mode only. The FaqItem id that should be open on first render.
openIdstring | nullControlled open id. Pair with onOpenChange. Single mode only.
onOpenChange(next: string | null) => voidFires when the open item changes. Single mode only.

FaqItem

PropTypeDefaultDescription
idstringStable id used for ARIA wiring and single-open coordination. Auto-generated if omitted.
question*ReactNodeThe trigger label.
defaultOpenbooleanfalseOpen by default in non-single, uncontrolled mode. Ignored when wrapped in <Faq single>.
openbooleanControlled open state. Wins over defaultOpen and the single-mode context.
onOpenChange(open: boolean) => voidFires whenever this item's open state changes.
children*ReactNodeThe answer body. Any JSX — keep it short, prefer one paragraph or one short list.