# FAQ Smooth Accordion — React FAQ Section

> Single-column accordion whose panels spring open to their natural height while the plus icon folds into a minus and the open card lifts.

- Category: [FAQs](https://pixelpapa.com/components/faqs.md)
- Tags: faq, accordion, spring, disclosure, height
- Dependencies: motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/faqs/faq-smooth-accordion

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/faq-smooth-accordion.json"
```

The file is written to `components/pixelpapa/faq-smooth-accordion.tsx`. To install manually, run `npm install motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { motion, useReducedMotion } from "motion/react";
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

type FaqItem = { question: string; answer: string };

const defaultItems: FaqItem[] = [
  {
    question: "How does the 14-day trial work?",
    answer:
      "You get every Studio feature for 14 days, no card required. When the trial ends your account drops to the free plan. Nothing is deleted and the invoices you already sent stay live for your clients.",
  },
  {
    question: "Can I change plans or cancel mid-cycle?",
    answer:
      "Yes. Upgrades apply immediately and you pay only the prorated difference. Downgrades and cancellations take effect at the end of the billing period, and you can export everything before then.",
  },
  {
    question: "Which payment methods can my clients use?",
    answer:
      "Cards, wallet payments and bank transfers (ACH and SEPA) in 34 currencies. Payouts land in your bank account within two business days.",
  },
  {
    question: "Do you take a cut of my invoices?",
    answer:
      "No. Cadence is a flat subscription. The only per-payment cost is the processor's fee, shown to you before you hit send: 1.4% for European cards, 2.9% elsewhere.",
  },
  {
    question: "Is my financial data safe?",
    answer:
      "Everything is encrypted in transit and at rest, backed up hourly to two regions, and audited against SOC 2 Type II every year. Passkeys and two-factor sign-in are available on all plans.",
  },
  {
    question: "Can my accountant get access?",
    answer:
      "Invite them as a free read-only seat. They can download reports, tax summaries and receipts without touching your invoices or seeing client notes.",
  },
];

/** Two identical bars: the second one rotates from vertical to flat, so a plus folds into a minus. */
function PlusMinus({ open, instant }: { open: boolean; instant: boolean }) {
  const transition = instant ? { duration: 0 } : { type: "spring" as const, stiffness: 320, damping: 22 };
  return (
    <motion.span
      aria-hidden
      initial={false}
      animate={{ rotate: open ? 180 : 0 }}
      transition={transition}
      className={cn(
        "relative flex size-8 shrink-0 items-center justify-center rounded-full border transition-colors duration-300",
        open
          ? "border-primary bg-primary text-primary-foreground"
          : "border-border bg-background text-muted-foreground group-hover:border-foreground/30 group-hover:text-foreground",
      )}
    >
      <span className="absolute h-[1.5px] w-3 rounded-full bg-current" />
      <motion.span
        initial={false}
        animate={{ rotate: open ? 0 : 90 }}
        transition={transition}
        className="absolute h-[1.5px] w-3 rounded-full bg-current"
      />
    </motion.span>
  );
}

type SmoothAccordionProps = {
  items: FaqItem[];
  /** Index that starts open; `null` starts fully collapsed. */
  defaultOpen?: number | null;
  className?: string;
};

export function SmoothAccordion({ items, defaultOpen = 0, className }: SmoothAccordionProps) {
  const [open, setOpen] = useState<number | null>(defaultOpen);
  const reduce = useReducedMotion() ?? false;
  const uid = useId();
  const triggers = useRef<Array<HTMLButtonElement | null>>([]);
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 260, damping: 30, mass: 0.9 };

  function onKeyDown(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    const last = items.length - 1;
    let next: number | null = null;
    if (e.key === "ArrowDown") next = i === last ? 0 : i + 1;
    else if (e.key === "ArrowUp") next = i === 0 ? last : i - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = last;
    if (next === null) return;
    e.preventDefault();
    triggers.current[next]?.focus();
  }

  return (
    <div className={cn("space-y-3", className)}>
      {items.map((item, i) => {
        const isOpen = open === i;
        return (
          <motion.div
            key={item.question}
            initial={{ opacity: 0, y: 16 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true }}
            transition={{ duration: reduce ? 0 : 0.5, delay: reduce ? 0 : i * 0.05, ease: [0.22, 1, 0.36, 1] }}
            className={cn(
              "rounded-2xl border transition-[background-color,border-color,box-shadow] duration-300",
              isOpen
                ? "border-primary/30 bg-card shadow-xl shadow-primary/[0.07]"
                : "border-border bg-card/40 hover:bg-card",
            )}
          >
            <h3>
              <button
                ref={(el) => {
                  triggers.current[i] = el;
                }}
                type="button"
                id={`${uid}-trigger-${i}`}
                aria-expanded={isOpen}
                aria-controls={`${uid}-panel-${i}`}
                onClick={() => setOpen(isOpen ? null : i)}
                onKeyDown={(e) => onKeyDown(e, i)}
                className="group flex w-full items-center gap-4 rounded-2xl px-5 py-5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-6"
              >
                <span className="flex-1 text-base font-medium text-foreground sm:text-lg">{item.question}</span>
                <PlusMinus open={isOpen} instant={reduce} />
              </button>
            </h3>
            <motion.div
              id={`${uid}-panel-${i}`}
              role="region"
              aria-labelledby={`${uid}-trigger-${i}`}
              aria-hidden={!isOpen}
              initial={false}
              animate={{ height: isOpen ? "auto" : 0 }}
              transition={spring}
              className="overflow-hidden"
            >
              <motion.p
                initial={false}
                animate={
                  isOpen ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 0, y: -8, filter: "blur(4px)" }
                }
                transition={{ duration: reduce ? 0 : 0.35, delay: isOpen && !reduce ? 0.08 : 0 }}
                className="px-5 pr-14 pb-6 text-[15px] leading-relaxed text-pretty text-muted-foreground sm:px-6 sm:pr-20"
              >
                {item.answer}
              </motion.p>
            </motion.div>
          </motion.div>
        );
      })}
    </div>
  );
}

export default function FaqSmoothAccordion({ items = defaultItems }: { items?: FaqItem[] }) {
  return (
    <section className="relative overflow-hidden bg-background px-6 py-20 sm:py-24">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-0 top-0 h-80 [background:radial-gradient(50%_80%_at_50%_0%,color-mix(in_oklch,var(--primary)_14%,transparent),transparent)]"
      />
      <div className="relative mx-auto max-w-3xl">
        <div className="text-center">
          <p className="inline-flex items-center gap-2 rounded-full border border-border bg-card/70 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur">
            <span className="size-1.5 rounded-full bg-primary" />
            FAQ
          </p>
          <h2 className="mt-5 text-4xl font-semibold tracking-tight text-balance text-foreground sm:text-5xl">
            Questions, <span className="font-serif font-normal text-primary italic">answered</span>
          </h2>
          <p className="mx-auto mt-4 max-w-md text-pretty text-muted-foreground">
            What freelancers ask us before they move their invoicing to Cadence.
          </p>
        </div>

        <SmoothAccordion items={items} className="mt-12" />

        <p className="mt-8 text-center text-sm text-muted-foreground">
          Something else on your mind?{" "}
          <a
            href="#"
            className="font-medium text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-primary"
          >
            Write to the team
          </a>{" "}
          and a human replies within a day.
        </p>
      </div>
    </section>
  );
}
```

## AI prompt

```text
Add the "FAQ Smooth Accordion" component from PixelPapa UI to this project.

What it is: Single-column accordion whose panels spring open to their natural height while the plus icon folds into a minus and the open card lifts.
Category: FAQs. Depends on: motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/faq-smooth-accordion.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/faq-smooth-accordion.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/faq-smooth-accordion" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More faqs

- [FAQ Chat Style](https://pixelpapa.com/components/faqs/faq-chat-style.md) (Pro): A support chat window where tapping a suggested question sends it as a bubble, shows a typing indicator, then types the answer in word by word.
- [FAQ Grid Cards](https://pixelpapa.com/components/faqs/faq-grid-cards.md): A grid of question cards that flip over in 3D on a spring to show the answer on the back; cards peek on hover and a control flips the whole deck in a wave.
- [FAQ Numbered Editorial](https://pixelpapa.com/components/faqs/faq-numbered-editorial.md): Magazine-style numbered questions in a serif: a rule draws across the open row, the label rolls from Read to Close and a short answer sits beside the long one.
- [FAQ Search Filter](https://pixelpapa.com/components/faqs/faq-search-filter.md) (Pro): Type to filter questions live: matches are highlighted in both question and answer, rows reflow with layout animation and topic chips update their counts.
- [FAQ Two Column Categories](https://pixelpapa.com/components/faqs/faq-two-column-categories.md) (Pro): Category tabs on the left with a sliding highlight; the question list on the right swaps with a stagger and each row expands in place.
- [FAQ With Contact Card](https://pixelpapa.com/components/faqs/faq-with-contact-card.md) (Pro): Accordion beside a sticky support card; marking an answer as unhelpful makes the card hop, pulse and rewrite itself around that question.
