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-accordionfaq-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-accordionSource
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.