Tessli

FAQ that answers real objections

Answers the questions buyers ask before committing, with direct answers and a real contact route, using native disclosure widgets.

  • Section
  • FAQ
  • Community

Community — not yet reviewed by a maintainer

Preview

Theme
Width

1440 px wide. Open this preview on its own ↗

Install

npx shadcn@latest add online-scope-lab/tessli/faq

Run it in a project set up with shadcn/ui. It adds faq.tsx to your components folder, and the item takes on your project’s theme.

When to use it

Use when

  • A marketing or pricing page has four to eight recurring questions about data, billing, cancellation or access.
  • The answers are short and stable enough to live on the page.

Avoid when

  • The questions need long, step-by-step answers; link to documentation instead.
  • You have fewer than three real questions; answer them in the page copy.
  • The content is support troubleshooting that changes often; keep it in a help centre.

Common AI default vs. this

The common default

A centred "Frequently Asked Questions" heading over a JavaScript accordion of filler questions such as "What is [product]?" and "Why choose us?", answered with marketing copy, only one item open at a time, and no way to reach a person.

What this item does instead

  • Questions are real objections (export, cancellation, billing, sharing, offline) in the words a buyer would use.
  • Each answer starts with the direct answer ("Yes.", "Only the people you invite.") before the detail.
  • Native details and summary elements need no JavaScript, work with the keyboard and screen readers out of the box, and let people open several answers at once.
  • A heading that says what the questions are about, plus a real contact route for anything not covered.
  • On wide screens the heading column stays in view beside the questions instead of a centred single column.

Why it is built this way

An FAQ earns its place when it removes a reason not to buy. That means real objections: can I leave with my data, what happens if I stop paying, who can see my work. Filler questions about what the product is belong in the hero, not here.

The section uses native details and summary elements. They are keyboard- and screen-reader-accessible without any script, and people can open more than one answer to compare them. Keep answers to a few sentences and lead with the direct answer.

The contact line is part of the item, not decoration: if the answer is not on the page, say who answers and how fast, and only if that is true.

Rules

How it breaks

Common mistakes when adapting this item.

  • Padding the list with questions nobody asks to make the section look complete.
  • Hiding prices, limits or cancellation terms in an FAQ instead of stating them where people decide.
  • Replacing the native disclosure with a custom accordion that loses keyboard and find-in-page support.
  • Linking the contact line to a form that does not exist yet.

States, responsive behaviour and accessibility

States
default
Desktop
Two columns from 1024px; the heading, description and contact route stay in view beside the questions.
Mobile
One column; the heading block comes first, then full-width questions with 44px touch targets.

Accessibility

  • The section is labelled by its h2; questions are summary elements inside details, so their expanded state is announced natively.
  • The plus icon is decorative (aria-hidden) and rotates into a cross; the open state never relies on the icon alone.
  • Each summary is at least 44px tall and shows a focus ring.
  • The rotation honours reduced motion.

Code

web/registry/items/faq/faq.tsx

import * as React from "react";

import { cn } from "@/registry/shadcn/lib/utils";

export type FaqItem = {
  /** The question as a buyer would ask it, not a marketing prompt. */
  question: string;
  /** A direct answer first, then the detail. Keep it to a few sentences. */
  answer: React.ReactNode;
};

export type FaqProps = Omit<React.ComponentProps<"section">, "title"> & {
  title: React.ReactNode;
  /** One sentence on what these questions cover. */
  description?: React.ReactNode;
  items: FaqItem[];
  /** Where to go when the answer is not here: a real contact route. */
  contact?: React.ReactNode;
  /** Open the first answer on load. Off by default. */
  openFirst?: boolean;
};

export function Faq({
  title,
  description,
  items,
  contact,
  openFirst = false,
  className,
  ...props
}: FaqProps) {
  const headingId = React.useId();

  return (
    <section
      aria-labelledby={headingId}
      data-slot="faq"
      className={cn("bg-background text-foreground", className)}
      {...props}
    >
      <div className="mx-auto grid w-full max-w-6xl gap-8 px-4 py-12 sm:px-6 sm:py-16 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-16 lg:px-8 lg:py-24">
        <div className="lg:sticky lg:top-8 lg:self-start">
          <h2
            id={headingId}
            className="text-3xl leading-tight font-semibold tracking-tight text-balance sm:text-4xl"
          >
            {title}
          </h2>
          {description ? (
            <p className="mt-4 max-w-prose text-base leading-relaxed text-pretty text-muted-foreground">
              {description}
            </p>
          ) : null}
          {contact ? (
            <div className="mt-6 max-w-prose text-sm leading-relaxed text-muted-foreground">
              {contact}
            </div>
          ) : null}
        </div>

        <div className="border-t border-border">
          {items.map((item, index) => (
            <details
              key={item.question}
              open={openFirst && index === 0}
              className="group border-b border-border"
            >
              <summary className="flex min-h-11 cursor-pointer list-none items-start justify-between gap-4 rounded-sm py-4 text-left text-base leading-snug font-medium focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none [&::-webkit-details-marker]:hidden">
                <span>{item.question}</span>
                <svg
                  aria-hidden="true"
                  viewBox="0 0 16 16"
                  className="mt-1 size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-45 motion-reduce:transition-none"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                  strokeLinecap="round"
                >
                  <path d="M8 3v10M3 8h10" />
                </svg>
              </summary>
              <div className="max-w-prose pr-8 pb-5 text-sm leading-relaxed text-pretty text-muted-foreground">
                {item.answer}
              </div>
            </details>
          ))}
        </div>
      </div>
    </section>
  );
}

Credits and licence

Contributors
None credited yet.
Provenance
Original work
Licence
MIT
Review
Community — not yet reviewed by a maintainer

For agents

The same taste notes and code as structured data.