Always-open FAQ as a description list on Grid. Stack sets question and answer pairs in up to three columns; split rules the pairs apart and sets each question beside its answer when wide.
import type { ComponentProps } from "react"
import { cva } from "class-variance-authority"
import { cn } from "cn"
import { Grid, type GridProps } from "@/components/editorial/grid"
// stack is pairs in columns, reading left to right; split rules the pairs
// apart and sets each question beside its answer once the list is wide.
// FaqList is a Grid rendered as a <dl>: Grid owns the container, columns and
// gap, and its wrapper is what each split item measures.
const faqListVariants = cva("group/faqs w-full", {
variants: {
variant: {
stack: "",
split: "divide-y divide-foreground/10",
},
},
defaultVariants: { variant: "stack" },
})
// split is a single-column list: its rules run between rows, so cols is 1.
type FaqListProps = ComponentProps<"dl"> &
(
| {
/**
* `stack` sets the pairs in columns. `split` is one column with a
* rule between pairs, and sets each question beside its answer when
* the list is wide.
*/
variant?: "stack"
/**
* The most columns the pairs use once the list is wide enough. A
* `split` list is always one column.
*/
cols?: 1 | 2 | 3
}
| {
/**
* `stack` sets the pairs in columns. `split` is one column with a
* rule between pairs, and sets each question beside its answer when
* the list is wide.
*/
variant: "split"
/**
* The most columns the pairs use once the list is wide enough. A
* `split` list is always one column.
*/
cols?: 1
}
)
/**
* A list of questions and answers for an FAQ section, rendered as a `<dl>`.
* Put a `FaqListItem` inside for each question.
*
* `stack` (the default) sets the pairs in one to three columns. `split` puts
* a rule between pairs and, once the list is 48rem wide, sets each question
* beside its answer. `className` lands on the `<dl>`, but its columns follow
* the width of a wrapper around it, so to narrow the list, size its parent.
*/
function FaqList({
className,
variant = "stack",
cols = 1,
...props
}: FaqListProps) {
return (
<Grid
data-slot="faq-list"
data-variant={variant}
render={<dl />}
cols={variant === "split" ? 1 : cols}
gap={variant === "split" ? "none" : "lg"}
className={cn(faqListVariants({ variant }), className)}
// Grid's props are typed for its default div; this list renders a <dl>.
{...(props as Omit<GridProps, "cols" | "gap">)}
/>
)
}
/**
* One question and its answer inside `FaqList`. Put a `FaqListQuestion` and a
* `FaqListAnswer` inside.
*
* In a `split` list it pads its row and, once the list is 48rem wide, sets
* the question in a narrower column beside the answer.
*/
function FaqListItem({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="faq-list-item"
className={cn(
"grid content-start gap-2",
"group-data-[variant=split]/faqs:py-8 group-data-[variant=split]/faqs:first:pt-0 group-data-[variant=split]/faqs:last:pb-0 group-data-[variant=split]/faqs:@3xl:grid-cols-[5fr_7fr] group-data-[variant=split]/faqs:@3xl:gap-8",
className
)}
{...props}
/>
)
}
/** The question, as a `<dt>` in semibold text, inside `FaqListItem`. */
function FaqListQuestion({ className, ...props }: ComponentProps<"dt">) {
return (
<dt
data-slot="faq-list-question"
className={cn("text-base/7 font-semibold text-foreground", className)}
{...props}
/>
)
}
/**
* The answer, as a `<dd>` in muted text, inside `FaqListItem`. It can hold
* several paragraphs with a small gap between them. Links inside are
* underlined, and a `<ul>` inside gets bullets.
*/
function FaqListAnswer({ className, ...props }: ComponentProps<"dd">) {
return (
<dd
data-slot="faq-list-answer"
className={cn(
"grid gap-2 text-sm/7 text-muted-foreground [&_a]:underline [&_a]:underline-offset-3 [&_ul]:list-disc [&_ul]:pl-5",
className
)}
{...props}
/>
)
}
export { FaqList, FaqListItem, FaqListQuestion, FaqListAnswer }
pnpm dlx shadcn add @01orbital/faq-listnpx shadcn add @01orbital/faq-listyarn shadcn add @01orbital/faq-listbunx --bun shadcn add @01orbital/faq-listFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/editorial/faq-list.tsx, as source you own and edit.
The example also uses heading and section. Install them too:
pnpm dlx shadcn add @01orbital/heading @01orbital/sectionnpx shadcn add @01orbital/heading @01orbital/sectionyarn shadcn add @01orbital/heading @01orbital/sectionbunx --bun shadcn add @01orbital/heading @01orbital/sectionimport {
FaqList,
FaqListAnswer,
FaqListItem,
FaqListQuestion,
} from "@/components/editorial/faq-list"
import { Heading } from "@/components/editorial/heading"
import {
Section,
SectionContent,
SectionDescription,
SectionHeader,
SectionTitleGroup,
} from "@/components/editorial/section"
const COPY = {
title: "Frequently asked questions",
body: "The three things every team asks before installing.",
} as const
const FAQS = [
{
id: "install",
question: "How do I install a block?",
answer:
"Run the command shown under the block. The CLI copies the source into your project.",
},
{
id: "existing",
question: "Does it fit an existing project?",
answer: "Yes. It uses the same tokens and primitives as stock shadcn.",
},
{
id: "license",
question: "Can I use it for clients?",
answer: "One license covers every project you build.",
},
] as const
// Centred introduction over the columns; cols={2} is the two-column edition.
export default function FaqListDemo() {
return (
<Section>
<SectionHeader align="center">
<SectionTitleGroup>
<Heading>{COPY.title}</Heading>
</SectionTitleGroup>
<SectionDescription>{COPY.body}</SectionDescription>
</SectionHeader>
<SectionContent>
<FaqList cols={3}>
{FAQS.map((faq) => (
<FaqListItem key={faq.id}>
<FaqListQuestion>{faq.question}</FaqListQuestion>
<FaqListAnswer>{faq.answer}</FaqListAnswer>
</FaqListItem>
))}
</FaqList>
</SectionContent>
</Section>
)
}Faq List is built from 4 parts. In the example they nest like this:
FaqList
└─ FaqListItem
├─ FaqListQuestion
└─ FaqListAnswerFaqList: A list of questions and answers for an FAQ section, rendered as a <dl>. Put a FaqListItem inside for each question.FaqListItem: One question and its answer inside FaqList. Put a FaqListQuestion and a FaqListAnswer inside.FaqListQuestion: The question, as a <dt> in semibold text, inside FaqListItem.FaqListAnswer: The answer, as a <dd> in muted text, inside FaqListItem. It can hold several paragraphs with a small gap between them. Links inside are underlined, and a <ul> inside gets bullets.A list of questions and answers for an FAQ section, rendered as a <dl>. Put a FaqListItem inside for each question.
stack (the default) sets the pairs in one to three columns. split puts a rule between pairs and, once the list is 48rem wide, sets each question beside its answer. className lands on the <dl>, but its columns follow the width of a wrapper around it, so to narrow the list, size its parent.
Prop
Type
It also accepts the HTML attributes of the element it renders.
One question and its answer inside FaqList. Put a FaqListQuestion and a FaqListAnswer inside.
In a split list it pads its row and, once the list is 48rem wide, sets the question in a narrower column beside the answer.
It accepts the HTML attributes of the element it renders.
The question, as a <dt> in semibold text, inside FaqListItem.
It accepts the HTML attributes of the element it renders.
The answer, as a <dd> in muted text, inside FaqListItem. It can hold several paragraphs with a small gap between them. Links inside are underlined, and a <ul> inside gets bullets.
It accepts the HTML attributes of the element it renders.
npm: class-variance-authority, cn. The CLI installs them for you.
Registry pieces it installs with it:
gridEyebrow
Short label set above a heading. Default is 12px uppercase Geist Mono with wide tracking in muted ink; sans is a 14px semibold line in sentence case. Pulls in the Geist Mono font item.
Features
Feature list parts: a Grid rendered as a list in one to four columns or a divided single column, with feature, title and description parts and an outline item that rules its left edge.