FAQ Tabbed Grid
BlockFrequently asked questions in category tabs, each category a grid of always-visible answers.
FAQs
Quick, complete answers to common questions about the platform, its services and its features.
- How do I install it?
- Run the init command in your project, then add components one at a time. Each arrives as source you own.
- What are the requirements?
- React 19 and Tailwind CSS 4. Every modern browser is supported.
- Is it free to use?
- Yes — free and open source, with no trial limits and no hidden costs.
Installation
The block is written to your blocks directory, and everything it is built from is installed alongside it.
pnpm dlx @dowel-ui/cli add faq-tabbed-gridBuilt from
A block is not a black box. Every piece is a component you already have documentation for, and can change independently.
Props
FaqTabbedGridBlock
| Prop | Type | Default |
|---|---|---|
categories | FaqTabbedGridCategory[] | DEFAULT_FAQ_TABBED_GRID_CATEGORIES |
categoriesLabelAccessible name of the tab list. | string | "Question categories" |
description | ReactNode | "Quick, complete answers to common questions about the platform, its services and its features." |
heading | ReactNode | "FAQs" |
headingLevelLevel of the section heading. Questions are the list's terms ( | 1 | 2 | 3 | 4 | 5 | 6 | 2 |
onValueChange | (value: string) => void | — |
valueControlled active category id. | string | — |
Plus every attribute of <section> except title, defaultValue.
Accessibility
Categories are real tabs (a named tablist, arrow keys, one panel each) whose underline slides between them. Each panel is a description list — questions as terms, answers as details — so the pairing is announced. Icons are decorative. The staggered rise of a newly shown panel collapses to nothing under reduced motion.
Source
This is exactly what dowel add faq-tabbed-grid writes, with imports rewritten to your own path alias. It is a starting point — edit it.
"use client";
// Ported from SmoothUI FAQ 1 (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { useId, type ComponentPropsWithRef, type CSSProperties, type ReactNode } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/tabs";
import { cn } from "@/lib/utils";
/*
* Frequently asked questions grouped into categories, each category a grid of
* question-and-answer pairs with every answer visible.
*
* The source's tab strip, with its underline sliding between tabs, is Dowel's
* underline tabs with the `slide` indicator — real tabs, with arrow keys and a
* panel each. Answers are a description list, because that is what a
* question/answer grid is (and why a question is a term, not a heading: a
* `dt` may not contain one). Switching category mounts the new panel, and its
* pairs rise in with a staggered CSS keyframe that the reduced-motion scale
* collapses to nothing.
*/
const PREFIX = "dowel-faq-tabbed-grid";
const STYLES = `
@keyframes ${PREFIX}-rise{from{opacity:0;translate:0 1.25rem}}
[data-slot=faq-tabbed-grid-list]>*{animation:${PREFIX}-rise var(--duration-slower) var(--ease-out-quint) both;animation-delay:calc(var(--${PREFIX}-index,0) * 100ms * var(--motion-scale,1))}
`;
export interface FaqTabbedGridItem {
question: string;
answer: ReactNode;
/** A small icon shown above the question. Decorative. */
icon?: ReactNode;
}
export interface FaqTabbedGridCategory {
/** The tab's value. It ends up inside id references, so no whitespace. */
id: string;
/** The tab's label. */
name: string;
items: FaqTabbedGridItem[];
}
export const DEFAULT_FAQ_TABBED_GRID_CATEGORIES: FaqTabbedGridCategory[] = [
{
id: "general",
name: "General",
items: [
{
question: "How do I install it?",
answer:
"Run the init command in your project, then add components one at a time. Each arrives as source you own.",
},
{
question: "What are the requirements?",
answer: "React 19 and Tailwind CSS 4. Every modern browser is supported.",
},
{
question: "Is it free to use?",
answer: "Yes — free and open source, with no trial limits and no hidden costs.",
},
],
},
{
id: "components",
name: "Components",
items: [
{
question: "How many components are included?",
answer:
"Well over a hundred, from buttons and forms to charts, overlays and whole page sections.",
},
{
question: "Can I customise the styling?",
answer:
"Yes. Everything reads design tokens, so a theme changes the whole set, and any single file can be edited.",
},
{
question: "Are the components accessible?",
answer:
"Each follows the relevant WAI-ARIA pattern, supports the keyboard and is tested with axe.",
},
],
},
{
id: "support",
name: "Support",
items: [
{
question: "How can I get help?",
answer: "Ask in the community discussions, or email the support team directly.",
},
{
question: "Do you offer custom development?",
answer: "Yes, for teams that need components built to their own specification.",
},
{
question: "What is your response time?",
answer: "Usually within one working day. Mark urgent issues as high priority.",
},
],
},
];
export interface FaqTabbedGridBlockProps extends Omit<
ComponentPropsWithRef<"section">,
"title" | "defaultValue"
> {
heading?: ReactNode;
description?: ReactNode;
categories?: FaqTabbedGridCategory[];
/** Controlled active category id. */
value?: string;
/** Initial category id when uncontrolled. Defaults to the first. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Accessible name of the tab list. */
categoriesLabel?: string;
/**
* Level of the section heading. Questions are the list's terms (`dt`), which
* may not contain headings, so they add no level of their own.
*/
headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
}
/** The icon shown when an item brings none: a question mark in a circle. */
function QuestionIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01" />
</svg>
);
}
/** Frequently asked questions in category tabs, each a grid of always-visible answers. */
export function FaqTabbedGridBlock({
heading = "FAQs",
description = "Quick, complete answers to common questions about the platform, its services and its features.",
categories = DEFAULT_FAQ_TABBED_GRID_CATEGORIES,
value,
defaultValue,
onValueChange,
categoriesLabel = "Question categories",
headingLevel = 2,
className,
...props
}: FaqTabbedGridBlockProps) {
const headingId = useId();
const Heading = `h${String(headingLevel)}` as "h2";
return (
<section
aria-labelledby={headingId}
data-slot="faq-tabbed-grid"
className={cn("w-full bg-muted py-16 md:py-24", className)}
{...props}
>
<style href={PREFIX} precedence="dowel">
{STYLES}
</style>
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<div className="max-w-lg">
<Heading
id={headingId}
className="text-4xl font-semibold tracking-tight text-balance text-foreground"
>
{heading}
</Heading>
{description ? (
<p className="mt-4 text-lg text-balance text-muted-foreground">{description}</p>
) : null}
</div>
<Tabs
value={value}
defaultValue={defaultValue ?? categories[0]?.id}
onValueChange={onValueChange}
className="mt-8 md:mt-12"
>
<TabsList
variant="underline"
indicator="slide"
aria-label={categoriesLabel}
className="flex w-full flex-wrap justify-start"
>
{categories.map((category) => (
<TabsTrigger key={category.id} value={category.id} variant="underline">
{category.name}
</TabsTrigger>
))}
</TabsList>
{categories.map((category) => (
<TabsContent key={category.id} value={category.id} className="mt-8">
<dl
data-slot="faq-tabbed-grid-list"
className="grid gap-10 sm:grid-cols-2 md:gap-12 lg:grid-cols-3"
>
{category.items.map((item, index) => (
<div
key={item.question}
style={{ [`--${PREFIX}-index`]: index } as CSSProperties}
className="flex flex-col gap-3"
>
<dt className="flex flex-col gap-3 font-semibold text-foreground">
<span
aria-hidden="true"
className="flex size-8 items-center justify-center rounded-md border border-border bg-card text-foreground [&_svg]:size-4"
>
{item.icon ?? <QuestionIcon />}
</span>
{item.question}
</dt>
<dd className="text-muted-foreground">{item.answer}</dd>
</div>
))}
</dl>
</TabsContent>
))}
</Tabs>
</div>
</section>
);
}