FAQ Accordion

FAQs

If you can't find the answer to your question below, email us at hello@example.com

Yes. Invite your team from settings and everyone shares one workspace. Billing moves to a single invoice once you add a second seat.

A frequently asked questions section: a heading, one line pointing to an email address, and a single raised card of questions split by hairlines. Click a question and its answer opens under it. By default one answer is open at a time, so opening a second closes the first. Switch to Many open to let them stack, use Replay to see the entrance again, or Full screen to see it at page width.

The chevron folds, it does not spin

Most accordions rotate the arrow 180°. Here the chevron is one path whose two ends swap height with its tip, so it passes through a flat line on the way from down to up. The two shapes use the same path commands, which lets Motion animate the d attribute directly. It runs on a 450ms spring with a little bounce, so the tip overshoots slightly and settles. The stroke also darkens from muted to foreground while the answer is open.

Opening an answer

  • The panel grows from zero height on a 400ms spring with no bounce, so it pushes the rows below it down without wobbling.
  • The answer text trails the panel by 60ms. It fades in, drops 6px into place, and sharpens from a 4px blur, over 300ms.
  • Closing is quicker than opening. The text fades out in 150ms while the panel shrinks, so you never see words hanging over an empty gap.
  • Opening one question while another is open runs both at once. The two springs share a duration, so the card's total height barely moves.

The entrance

The first time the section scrolls into view, the heading, the line under it, and then each row rise 8px from 98% scale and fade in. Rows are 40ms apart, so seven questions take under 300ms to arrive. It plays once. Pass reveal={false} to show everything at rest.

Narrow containers

The block responds to its own width. Below @md the rows tighten to 20px padding and the heading drops a size. Long questions wrap with text-pretty and the chevron stays pinned to the right. On wide containers the answer keeps a right margin, so its lines never run under the chevron.

Accessibility

  • Each question is a real button inside an h3, with aria-expanded and aria-controls. Its answer is a region labelled by the question.
  • Esc closes the focused question.
  • The email is a mailto: link.
  • With reduced motion the panel opens at once, the chevron swaps without folding, and the entrance becomes a plain fade.

Usage

<FaqAccordion
  heading="FAQs"
  description="If you can't find the answer to your question below, email us at"
  email="care@example.com"
  defaultOpen={0}
  items={[
    {
      question: "Can I use it at my company?",
      answer: "Yes. Invite your team from settings and everyone shares one workspace.",
    },
  ]}
/>
components/blocks/faq/faq-accordion.tsx
"use client";

import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { useId, useState } from "react";

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

export type FaqItem = {
  question: string;
  answer: React.ReactNode;
};

export type FaqAccordionProps = {
  heading?: string;
  /** Text before the email link under the heading. */
  description?: string;
  /** Shown as a mailto link after the description. Leave unset to hide it. */
  email?: string;
  items?: FaqItem[];
  /** Index of the question that starts open. */
  defaultOpen?: number | null;
  /** Lets more than one answer stay open. */
  multiple?: boolean;
  /** Fades the heading and rows in, one after the other, the first time the
   * block scrolls into view. */
  reveal?: boolean;
  className?: string;
};

const DEFAULT_ITEMS: FaqItem[] = [
  {
    question: "Can I use it at my company?",
    answer:
      "Yes. Invite your team from settings and everyone shares one workspace. Billing moves to a single invoice once you add a second seat.",
  },
  {
    question: "I've already used my trial. Can I get another one?",
    answer:
      "Trials run once per account, but write to us and tell us what you're trying out. We're happy to extend it if you need a little longer.",
  },
  {
    question: "Where do I connect more accounts?",
    answer:
      "Open settings, then Accounts, and choose Add account. You can connect as many calendars and inboxes as you like on every plan.",
  },
  {
    question: "How do you protect my privacy?",
    answer:
      "Your data is encrypted in transit and at rest, we never sell it, and you can export or delete everything from settings at any time.",
  },
  {
    question: "When can I use it on my device?",
    answer:
      "It runs in the browser today, with native apps for macOS and iOS. Windows and Android are next on the list.",
  },
  {
    question: "Where can I send a feature request or report a bug?",
    answer:
      "Use Send feedback in the help menu. Every message is read by the team, and we reply when something you asked for ships.",
  },
  {
    question: "Which video call providers do you support?",
    answer:
      "Zoom, Google Meet, Microsoft Teams, and any link you paste into an event. Join buttons appear a few minutes before the call starts.",
  },
];

// The chevron is one path whose ends swap height, so it folds flat through
// the middle instead of spinning. Both shapes share the same commands, which
// lets Motion interpolate the `d` attribute directly.
const CHEVRON_DOWN = "M3 6.25L8 9.75L13 6.25";
const CHEVRON_UP = "M3 9.75L8 6.25L13 9.75";

const spring = { type: "spring", duration: 0.4, bounce: 0 } as const;
const settle = { type: "spring", duration: 0.45, bounce: 0.3 } as const;

const rise = {
  hidden: { opacity: 0, y: 8, scale: 0.98 },
  shown: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.5, ease: [0.23, 1, 0.32, 1] } },
} as const;

function Item({
  item,
  open,
  onToggle,
}: {
  item: FaqItem;
  open: boolean;
  onToggle: () => void;
}) {
  const id = useId();
  const reduceMotion = useReducedMotion();
  const height = reduceMotion ? { duration: 0 } : spring;

  return (
    <motion.div variants={rise} className="border-t border-border first:border-t-0">
      <h3>
        <button
          type="button"
          id={`${id}-trigger`}
          aria-expanded={open}
          aria-controls={`${id}-panel`}
          onClick={onToggle}
          onKeyDown={(e) => {
            if (e.key === "Escape" && open) onToggle();
          }}
          className="flex w-full cursor-pointer items-center gap-4 p-5 text-left text-base/7 font-medium text-foreground outline-none transition-colors duration-150 hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:ring-[1.5px] focus-visible:ring-ring/50 focus-visible:ring-inset active:bg-muted/70 @md:px-6"
        >
          <span className="min-w-0 flex-1 text-pretty">{item.question}</span>
          <svg viewBox="0 0 16 16" aria-hidden className="size-4 shrink-0 overflow-visible">
            <motion.path
              initial={false}
              animate={{ d: open ? CHEVRON_UP : CHEVRON_DOWN }}
              transition={reduceMotion ? { duration: 0 } : settle}
              fill="none"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
              className={cn(
                "transition-[stroke] duration-200",
                open ? "stroke-foreground" : "stroke-muted-foreground/70",
              )}
            />
          </svg>
        </button>
      </h3>
      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            id={`${id}-panel`}
            role="region"
            aria-labelledby={`${id}-trigger`}
            initial={{ height: 0 }}
            animate={{ height: "auto", transition: height }}
            exit={{ height: 0, transition: height }}
            className="overflow-hidden"
          >
            {/* The answer trails the panel slightly, and leaves faster than it
                came, so closing never shows text over an empty gap. */}
            <motion.div
              initial={{ opacity: 0, y: -6, filter: "blur(4px)" }}
              animate={{
                opacity: 1,
                y: 0,
                filter: "blur(0px)",
                transition: { duration: 0.3, delay: 0.06, ease: [0.23, 1, 0.32, 1] },
              }}
              exit={{ opacity: 0, y: -4, filter: "blur(2px)", transition: { duration: 0.15 } }}
              className="px-5 pb-5 text-base/7 text-pretty text-muted-foreground @md:px-6 @md:pr-16 [&_a]:text-foreground [&_a]:underline [&_a]:underline-offset-3"
            >
              {item.answer}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

export function FaqAccordion({
  heading = "FAQs",
  description = "If you can't find the answer to your question below, email us at",
  email = "hello@example.com",
  items = DEFAULT_ITEMS,
  defaultOpen = null,
  multiple = false,
  reveal = true,
  className,
}: FaqAccordionProps) {
  const headingId = useId();
  const [open, setOpen] = useState<number[]>(defaultOpen == null ? [] : [defaultOpen]);

  const toggle = (i: number) =>
    setOpen((current) =>
      current.includes(i)
        ? current.filter((n) => n !== i)
        : multiple
          ? [...current, i]
          : [i],
    );

  return (
    <section
      aria-labelledby={headingId}
      className={cn("@container w-full px-5 py-16 @md:px-8 @3xl:py-24", className)}
    >
      {/* Transforms drop out for reduced motion; the fades stay. */}
      <MotionConfig reducedMotion="user">
        <motion.div
          initial={reveal ? "hidden" : false}
          whileInView="shown"
          viewport={{ once: true, amount: 0.2 }}
          transition={{ staggerChildren: 0.04, delayChildren: 0.05 }}
          className="mx-auto flex max-w-3xl flex-col items-center"
        >
          <motion.h2
            id={headingId}
            variants={rise}
            className="text-center text-3xl font-medium tracking-tight text-balance text-foreground @md:text-4xl"
          >
            {heading}
          </motion.h2>
          {(description || email) && (
            <motion.p
              variants={rise}
              className="mt-3 max-w-md text-center text-base/7 text-pretty text-muted-foreground"
            >
              {description}{" "}
              {email && (
                <a
                  href={`mailto:${email}`}
                  className="rounded-sm text-foreground underline decoration-border underline-offset-3 transition-[text-decoration-color] duration-150 outline-none hover:decoration-foreground focus-visible:ring-[1.5px] focus-visible:ring-ring/50"
                >
                  {email}
                </a>
              )}
            </motion.p>
          )}
          <motion.div
            variants={{ hidden: {}, shown: { transition: { staggerChildren: 0.04 } } }}
            className="mt-10 w-full overflow-hidden rounded-xl bg-card shadow-(--custom-shadow)"
          >
            {items.map((item, i) => (
              <Item
                key={item.question}
                item={item}
                open={open.includes(i)}
                onToggle={() => toggle(i)}
              />
            ))}
          </motion.div>
        </motion.div>
      </MotionConfig>
    </section>
  );
}

Search concepts

Search for a command to run...