FAQ accordion

Five questions, one open at a time, with a still-have-questions contact card beside it on desktop.

blockaccordionanyfaqsupport
$npx shadcn@latest add @storekit/faq-accordion
faq-accordion

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/faq-accordion
$pnpm dlx shadcn@latest add @storekit/faq-accordion
$yarn dlx shadcn@latest add @storekit/faq-accordion
$bunx --bun shadcn@latest add @storekit/faq-accordion

Source

components/storekit/marketing/faq-accordion.tsx
125 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ChevronDown, MessageCircle } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* FAQ with one row open at a time. Each row is a real button with
   aria-expanded and a labelled region. A contact card sits beside it on
   desktop and below it on mobile. */

type Faq = { q: string; a: string }

const SAMPLE: Faq[] = [
  { q: "How do I know what is in it?", a: "Every batch is tested by an independent lab for ingredient amounts, heavy metals and microbes. The results are on each product page, and the code on your pouch links to your batch." },
  { q: "How fast do orders ship?", a: "Orders placed before 2pm ET ship the same business day. Standard delivery takes 3 to 5 business days in the US." },
  { q: "How does subscribe and save work?", a: "You save 15% on every delivery and pick how often it ships. Skip, swap or cancel any time from your account, no calls needed." },
  { q: "What if my order arrives damaged?", a: "Send a photo within 7 days and we reship at no cost. Unopened products can be returned within 30 days." },
  { q: "Which payment methods do you accept?", a: "Major cards, Shop Pay and PayPal. Every checkout is encrypted end to end and we never store card numbers." },
]

export function FaqAccordion({
  items = SAMPLE,
  eyebrow = "FAQ",
  title = "Questions, answered.",
  contactTitle = "Still have questions?",
  contactBody = "A real person on our team replies within four hours on business days.",
  contactLabel = "Contact us",
  onContact,
  defaultOpen = 0,
  className,
}: {
  items?: Faq[]
  eyebrow?: string
  title?: string
  contactTitle?: string
  contactBody?: string
  contactLabel?: string
  onContact?: () => void
  /** Index open on mount, or -1 for none. */
  defaultOpen?: number
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("marketing", "faq-accordion")
  const [open, setOpen] = React.useState(defaultOpen)
  const uid = React.useId()
  return (
    <section ref={sk.ref as React.Ref<HTMLElement>} {...sk.attrs} className={cn("w-full py-16 sm:py-24", className)}>
      <div className="mx-auto grid max-w-6xl gap-10 px-4 sm:px-8 lg:grid-cols-[minmax(0,1fr)_320px] lg:gap-16">
        <div>
          <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{eyebrow}</p>
          <h2 className="mt-3 font-sk-display text-3xl font-semibold leading-[1.1] tracking-[-0.02em] sm:text-4xl">{title}</h2>
          <div className="mt-8 border-t border-sk-line">
            {items.map((item, i) => {
              const isOpen = open === i
              const btn = `${uid}-b${i}`
              const panel = `${uid}-p${i}`
              return (
                <div key={item.q} className="border-b border-sk-line">
                  <h3>
                    <button
                      id={btn}
                      type="button"
                      aria-expanded={isOpen}
                      aria-controls={panel}
                      onClick={() => {
                        if (!isOpen) sk.emit("interact", `faq_open_${i + 1}`)
                        setOpen(isOpen ? -1 : i)
                      }}
                      className="group flex min-h-14 w-full items-center justify-between gap-4 py-4 text-left text-[15px] font-medium text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
                    >
                      {item.q}
                      <span className={cn("grid h-8 w-8 shrink-0 place-items-center rounded-sk-chip border border-sk-line transition-[transform,background-color] duration-300 ease-sk-out group-hover:bg-sk-ink/[0.04]", isOpen && "rotate-180")}>
                        <ChevronDown size={16} />
                      </span>
                    </button>
                  </h3>
                  <AnimatePresence initial={false}>
                    {isOpen ? (
                      <motion.div
                        id={panel}
                        role="region"
                        aria-labelledby={btn}
                        initial={{ height: 0, opacity: 0 }}
                        animate={{ height: "auto", opacity: 1 }}
                        exit={{ height: 0, opacity: 0 }}
                        transition={{ duration: reduce ? 0 : 0.32, ease: [0.16, 1, 0.3, 1] }}
                        className="overflow-hidden"
                      >
                        <p className="max-w-2xl pb-5 pr-12 text-[15px] leading-[1.5] text-sk-muted">{item.a}</p>
                      </motion.div>
                    ) : null}
                  </AnimatePresence>
                </div>
              )
            })}
          </div>
        </div>

        <aside className="lg:pt-[88px]">
          <div className="rounded-sk-card border border-sk-line bg-sk-card p-6">
            <span className="grid h-10 w-10 place-items-center rounded-sk-chip bg-sk-accent/10 text-sk-accent">
              <MessageCircle size={18} />
            </span>
            <p className="mt-4 font-sk-display text-xl font-semibold leading-[1.1] tracking-[-0.02em]">{contactTitle}</p>
            <p className="mt-2 text-[15px] leading-[1.5] text-sk-muted">{contactBody}</p>
            <Button
              className="mt-5 w-full"
              onClick={() => {
                sk.emit("convert", "contact")
                onContact?.()
              }}
            >
              {contactLabel}
            </Button>
          </div>
        </aside>
      </div>
    </section>
  )
}

Usage

Import from "@/components/storekit/marketing/faq-accordion" and pass your product data. The demo above exercises the full prop surface with sample values.

FAQ accordion, StoreKit