FAQ tabs
Questions grouped behind topic tabs, each answer a native disclosure.
npx shadcn@latest add https://ui.goodtabs.co/r/faq-tabs.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/faq-tabs
When am I charged?
On the day you subscribe, then every month or year after.
Do you send invoices?
Yes, by email after every payment, with your company details.
Billing, account and usage, with a short list each.
Two topics
Can I change the theme?
Everything reads from shadcn tokens, so edit the variables in your CSS.
Is dark mode included?
Yes. Light and dark are full mirrors of each other.
A different topic open first.
Usage
import { FaqTabs } from "@/components/faq-tabs"
<FaqTabs topics={[{ value: "billing", label: "Billing", items: [{ question: "When am I charged?", answer: "On signup." }] }]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| topics | FaqTopic[] | value, label and items of question and answer. | |
| label | string | "Topics" | Accessible name for the tab row. |
| defaultValue | string | Topic open first. Defaults to the first. |
Source
faq-tabs/faq-tabs.tsx
"use client";
import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
type FaqItem = { question: string; answer: React.ReactNode };
type FaqTopic = {
/** Unique id for the topic tab. */
value: string;
/** The tab label, for example "Billing". */
label: string;
/** Questions in this topic. */
items: FaqItem[];
};
type FaqTabsProps = Omit<React.ComponentProps<typeof Tabs>, "children"> & {
/** Topics, one per tab. */
topics: FaqTopic[];
/** Accessible name for the tab row. */
label?: string;
};
/**
* Questions grouped behind topic tabs. Each panel is a list of native details and summary pairs,
* with one open at a time per topic. Client component because radix tabs hold the active topic.
*/
export function FaqTabs({ topics, label = "Topics", defaultValue, className, ...props }: FaqTabsProps) {
return (
<Tabs
data-slot="faq-tabs"
defaultValue={defaultValue ?? topics[0]?.value}
className={cn("w-full gap-4", className)}
{...props}
>
<TabsList variant="line" aria-label={label} className="w-full justify-start overflow-x-auto border-b pb-1">
{topics.map((t) => (
<TabsTrigger
key={t.value}
value={t.value}
className="h-7 flex-none px-2.5 text-foreground/70 dark:text-foreground/70"
>
{t.label}
</TabsTrigger>
))}
</TabsList>
{topics.map((t) => (
<TabsContent key={t.value} value={t.value} className="divide-y rounded-lg border bg-card">
{t.items.map((item) => (
<details key={item.question} name={`faq-${t.value}`} className="group/faq">
<summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 text-sm font-medium text-foreground outline-none select-none hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset [&::-webkit-details-marker]:hidden">
{item.question}
<ChevronDownIcon
className="size-4 shrink-0 text-muted-foreground transition-transform duration-150 group-open/faq:rotate-180 motion-reduce:transition-none"
aria-hidden="true"
/>
</summary>
<div className="px-4 pb-4 text-sm text-muted-foreground">{item.answer}</div>
</details>
))}
</TabsContent>
))}
</Tabs>
);
}
export type { FaqTopic, FaqItem };
For AI agents
Hand your agent this page, the markdown at /components/faq-tabs/prompt.md, or the Copy AI prompt button above.
When to use
- A FAQ with more than about eight questions that fall into clear topics
- On pricing and support pages
- When readers know which topic they need
When not to use
- For a short list, use faq-accordion
- For documentation, which needs search and headings
- For questions that do not group
Agent notes
- Client component for the tabs; answers are native details elements, one open at a time per topic
- Keep topic labels to one word, they scroll sideways on phones
- Answer takes a string or any element, such as a link
- Inactive topics are unmounted, so find in page only sees the open one