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.
Source
This exact file renders the preview above.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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.