Editorial

Faq List

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.

Free, MIT licenseOpen in Browse
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 }

Installation

pnpm dlx shadcn add @01orbital/faq-list
npx shadcn add @01orbital/faq-list
yarn shadcn add @01orbital/faq-list
bunx --bun shadcn add @01orbital/faq-list

Free 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/section
npx shadcn add @01orbital/heading @01orbital/section
yarn shadcn add @01orbital/heading @01orbital/section
bunx --bun shadcn add @01orbital/heading @01orbital/section

Usage

import {
  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>
  )
}

Anatomy

Faq List is built from 4 parts. In the example they nest like this:

FaqList
└─ FaqListItem
   ├─ FaqListQuestion
   └─ FaqListAnswer
  • FaqList: 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.

API reference

FaqList

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.

FaqListItem

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.

FaqListQuestion

The question, as a <dt> in semibold text, inside FaqListItem.

It accepts the HTML attributes of the element it renders.

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.

It accepts the HTML attributes of the element it renders.

Dependencies

npm: class-variance-authority, cn. The CLI installs them for you.

Registry pieces it installs with it:

  • grid