Skip to content

Categorised FAQ

Questions grouped by subject, each group a labelled region.

Marketingstarterfaqgroupedcategorieslong

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'

/**
 * Categorised FAQ
 *
 * Grouped by subject, with each group a labelled region. For long FAQs the
 * grouping does more work than the disclosure behaviour — a reader scanning
 * for a billing question should not have to read six technical ones first.
 */
const groups = [
  {
    label: 'Licensing',
    items: [
      {
        q: 'Can I use this in a client project?',
        a: 'Yes. Components are copied into your codebase and you own the copy, including for client work.',
      },
      {
        q: 'Is there a per-project fee?',
        a: 'No. Pricing in this demo is per seat, and the seat covers unlimited projects.',
      },
    ],
  },
  {
    label: 'Technical',
    items: [
      {
        q: 'Does it require Next.js?',
        a: 'The primitives are plain React. The starters and the documentation site use the App Router, but nothing in the component layer depends on it.',
      },
      {
        q: 'What about Tailwind version 3?',
        a: 'The token bridge uses the Tailwind v4 `@theme` directive. On v3 you would map the same custom properties through the config file instead.',
      },
      {
        q: 'Can I use only the tokens?',
        a: 'Yes. The token file is standalone CSS with no JavaScript, so it can sit under an existing component set.',
      },
    ],
  },
  {
    label: 'Support',
    items: [
      {
        q: 'How do I report a problem?',
        a: 'Open a support request from the Forms catalogue. In this demo nothing is sent — the form demonstrates severity routing and reproduction fields.',
      },
      {
        q: 'Is there an accessibility audit?',
        a: 'The library implements published ARIA patterns and is tested against them, but no formal certification is claimed. See the accessibility documentation for what is and is not covered.',
      },
    ],
  },
]

export default function CategorisedFAQ() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Questions, by subject.
          </h2>
        </div>

        <div className="mt-10 flex flex-col gap-10">
          {groups.map((group) => (
            <section key={group.label} aria-labelledby={`faq-${group.label.toLowerCase()}`}>
              <div className="flex items-center gap-3 border-b border-line pb-3">
                <h3
                  id={`faq-${group.label.toLowerCase()}`}
                  className="text-md font-semibold text-ink-strong"
                >
                  {group.label}
                </h3>
                <Badge size="sm">{group.items.length}</Badge>
              </div>
              <dl className="mt-5 grid gap-x-10 gap-y-6 lg:grid-cols-2">
                {group.items.map((item) => (
                  <div key={item.q}>
                    <dt className="text-sm font-semibold text-ink">{item.q}</dt>
                    <dd className="mt-1.5 text-sm leading-relaxed text-ink-muted">{item.a}</dd>
                  </div>
                ))}
              </dl>
            </section>
          ))}
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/faq/categorised.tsx

import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'

/**
 * Categorised FAQ
 *
 * Grouped by subject, with each group a labelled region. For long FAQs the
 * grouping does more work than the disclosure behaviour — a reader scanning
 * for a billing question should not have to read six technical ones first.
 */
const groups = [
  {
    label: 'Licensing',
    items: [
      {
        q: 'Can I use this in a client project?',
        a: 'Yes. Components are copied into your codebase and you own the copy, including for client work.',
      },
      {
        q: 'Is there a per-project fee?',
        a: 'No. Pricing in this demo is per seat, and the seat covers unlimited projects.',
      },
    ],
  },
  {
    label: 'Technical',
    items: [
      {
        q: 'Does it require Next.js?',
        a: 'The primitives are plain React. The starters and the documentation site use the App Router, but nothing in the component layer depends on it.',
      },
      {
        q: 'What about Tailwind version 3?',
        a: 'The token bridge uses the Tailwind v4 `@theme` directive. On v3 you would map the same custom properties through the config file instead.',
      },
      {
        q: 'Can I use only the tokens?',
        a: 'Yes. The token file is standalone CSS with no JavaScript, so it can sit under an existing component set.',
      },
    ],
  },
  {
    label: 'Support',
    items: [
      {
        q: 'How do I report a problem?',
        a: 'Open a support request from the Forms catalogue. In this demo nothing is sent — the form demonstrates severity routing and reproduction fields.',
      },
      {
        q: 'Is there an accessibility audit?',
        a: 'The library implements published ARIA patterns and is tested against them, but no formal certification is claimed. See the accessibility documentation for what is and is not covered.',
      },
    ],
  },
]

export default function CategorisedFAQ() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Questions, by subject.
          </h2>
        </div>

        <div className="mt-10 flex flex-col gap-10">
          {groups.map((group) => (
            <section key={group.label} aria-labelledby={`faq-${group.label.toLowerCase()}`}>
              <div className="flex items-center gap-3 border-b border-line pb-3">
                <h3
                  id={`faq-${group.label.toLowerCase()}`}
                  className="text-md font-semibold text-ink-strong"
                >
                  {group.label}
                </h3>
                <Badge size="sm">{group.items.length}</Badge>
              </div>
              <dl className="mt-5 grid gap-x-10 gap-y-6 lg:grid-cols-2">
                {group.items.map((item) => (
                  <div key={item.q}>
                    <dt className="text-sm font-semibold text-ink">{item.q}</dt>
                    <dd className="mt-1.5 text-sm leading-relaxed text-ink-muted">{item.a}</dd>
                  </div>
                ))}
              </dl>
            </section>
          ))}
        </div>
      </Container>
    </section>
  )
}

components/ui/badge.tsx

import type { HTMLAttributes, ReactNode } from 'react'
import { variants } from '@/lib/variants'

/**
 * Badge
 *
 * A compact, non-interactive label. Tones map to the status token trios, and
 * because a badge is often the only signal in a dense table, the `dot` option
 * exists to add a second, colour-independent cue alongside the text.
 */
const badgeVariants = variants('inline-flex items-center gap-1.5 whitespace-nowrap font-medium', {
  variants: {
    tone: {
      neutral: 'bg-surface-sunken text-ink-muted border-line',
      accent: 'bg-accent-soft text-accent-soft-ink border-accent-line',
      success: 'bg-success-soft text-success border-success-line',
      warning: 'bg-warning-soft text-warning border-warning-line',
      danger: 'bg-danger-soft text-danger border-danger-line',
      info: 'bg-info-soft text-info border-info-line',
      inverse: 'bg-surface-inverse text-ink-inverse border-transparent',
    },
    appearance: {
      soft: 'border',
      outline: 'border bg-transparent',
      solid: 'border border-transparent',
    },
    size: {
      sm: 'h-4.5 rounded-sm px-1.5 text-2xs',
      md: 'h-5.5 rounded-sm px-2 text-xs',
    },
  },
  defaultVariants: { tone: 'neutral', appearance: 'soft', size: 'md' },
  compound: [
    { appearance: 'solid', tone: 'accent', class: 'bg-accent text-accent-ink' },
    { appearance: 'solid', tone: 'success', class: 'bg-success text-white' },
    { appearance: 'solid', tone: 'warning', class: 'bg-warning text-white' },
    { appearance: 'solid', tone: 'danger', class: 'bg-danger text-white' },
    { appearance: 'solid', tone: 'info', class: 'bg-info text-white' },
    { appearance: 'solid', tone: 'neutral', class: 'bg-ink text-ink-inverse' },
    { appearance: 'outline', tone: 'neutral', class: 'text-ink-muted' },
  ],
})

export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'inverse'

export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  tone?: BadgeTone
  appearance?: 'soft' | 'outline' | 'solid'
  size?: 'sm' | 'md'
  /** Adds a leading dot so the badge does not rely on hue alone. */
  dot?: boolean
  icon?: ReactNode
}

export function Badge({
  tone = 'neutral',
  appearance = 'soft',
  size = 'md',
  dot = false,
  icon,
  className,
  children,
  ...props
}: BadgeProps) {
  return (
    <span className={badgeVariants({ tone, appearance, size, className })} {...props}>
      {dot ? (
        <span className="size-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
      ) : null}
      {icon}
      {children}
    </span>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For long FAQs the grouping does more work than any disclosure behaviour — a reader scanning for a billing question should not have to read six technical ones first.

  • Name groups after what the reader wants, not after your internal teams.
  • Show the count per group so the reader can judge where to look.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Three groups
  • Per-group counts
  • Two-column answers

Accessibility

Labelled regions
Each group is a section with an aria-labelledby heading.
Heading order
Group headings are h3 under the section h2.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.