Fold
A neutral-elegant FAQ accordion of soft stacked cards: the open row lifts a hair off the stack, its chevron spins up in one accent hue, and the answer unfolds by hinging down from the top edge over a measured height animation. Full disclosure semantics with aria-expanded/aria-controls, labelled region panels, and Up/Down/Home/End focus movement; single-open by default with a singleOpen knob; instant open/close with the open row resting lifted under reduced motion.
motionfree
"use client";
import { AnimatePresence, motion, type Transition } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
/** A single question/answer pair in a {@link Fold}. */
export interface FoldItem {
/** The question shown on the disclosure trigger. */
question: string;
/** The answer revealed when the row unfolds. Plain text or rich nodes. */
answer: React.ReactNode;
}
/** Accent tokens for {@link Fold}. Everything defaults to a cool, elegant blue. */
export interface FoldColors {
/** Hue of the active chevron, focus ring, and the open row's hairline. @default "#93c5fd" */
accent?: string;
}
export interface FoldProps extends Omit<React.ComponentPropsWithoutRef<"section">, "color"> {
/** Question/answer pairs, rendered in order. @default five generic FAQs */
items?: FoldItem[];
/** Optional heading rendered as an h2 above the stack. Pass `null` to omit. @default "Frequently asked questions" */
heading?: React.ReactNode;
/**
* When true (default), opening a row folds the previously open one shut so
* only one answer shows at a time. When false, rows open independently.
* @default true
*/
singleOpen?: boolean;
/** Index open on mount. Pass -1 to start fully folded. @default 0 */
defaultOpenIndex?: number;
/** Accent tokens — the single cool hue on the active chevron and ring. @default `{ accent: "#93c5fd" }` */
colors?: FoldColors;
}
const DEFAULT_ITEMS: FoldItem[] = [
{
question: "How does the free plan work?",
answer:
"Everything you need to evaluate the product, with no time limit and no card required. Upgrade only when you outgrow the free limits.",
},
{
question: "Can I change plans at any time?",
answer:
"Yes. Switch up or down whenever you like — changes take effect immediately and we prorate the difference to the cent.",
},
{
question: "Do you offer refunds?",
answer:
"If something isn't right within the first 30 days, reach out and we'll issue a full refund. No forms, no friction.",
},
{
question: "Where is my data stored?",
answer:
"In the region you choose, encrypted at rest and in transit. You can export or delete everything at any time.",
},
{
question: "How do I get support?",
answer:
"Every plan includes email support with a same-business-day response. Paid plans add priority chat and a shared channel.",
},
];
const DEFAULT_ACCENT = "#93c5fd";
/**
* Fold — a neutral-elegant FAQ accordion where each row is a self-contained card
* in a soft stack. Opening a row lifts it a hair off the stack (a whisper of
* shadow and scale), spins its chevron to point up in one accent hue, and the
* answer physically unfolds: the panel measures its own height while the copy
* hinges down from the top edge like paper opening. Full disclosure semantics —
* every trigger is a real button with aria-expanded/aria-controls, the panel is a
* labelled region, Enter/Space toggle, and Up/Down/Home/End roam between
* questions. Single-open by default; `singleOpen={false}` lets rows open
* independently. Reduced motion swaps to an instant open/close with the open row
* resting lifted and the chevron already turned — no height tween, no hinge.
*/
export function Fold({
items = DEFAULT_ITEMS,
heading = "Frequently asked questions",
singleOpen = true,
defaultOpenIndex = 0,
colors,
className,
...props
}: FoldProps) {
const reducedMotion = useReducedMotion();
const baseId = React.useId();
const headingId = `${baseId}-heading`;
const accent = colors?.accent ?? DEFAULT_ACCENT;
const triggerRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const [openIndexes, setOpenIndexes] = React.useState<number[]>(() =>
defaultOpenIndex >= 0 && defaultOpenIndex < items.length ? [defaultOpenIndex] : []
);
const toggle = React.useCallback(
(index: number) => {
setOpenIndexes((current) => {
const isOpen = current.includes(index);
if (singleOpen) return isOpen ? [] : [index];
return isOpen ? current.filter((i) => i !== index) : [...current, index];
});
},
[singleOpen]
);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number | null = null;
if (event.key === "ArrowDown") next = (index + 1) % items.length;
else if (event.key === "ArrowUp") next = (index - 1 + items.length) % items.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = items.length - 1;
if (next === null) return;
event.preventDefault();
triggerRefs.current[next]?.focus();
},
[items.length]
);
// Height/hinge tween for the panel; snaps instantly under reduced motion.
const panelTransition: Transition = reducedMotion
? { duration: 0 }
: {
height: { duration: 0.36, ease: [0.16, 1, 0.3, 1] },
opacity: { duration: 0.26, ease: "easeOut" },
};
const liftTransition: Transition = reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 420, damping: 34, mass: 0.7 };
const chevronTransition: Transition = reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 460, damping: 30 };
return (
<section
aria-labelledby={heading == null ? undefined : headingId}
style={{ ["--fold-accent" as string]: accent }}
className={cn("w-full", className)}
{...props}
>
{heading != null ? (
<h2
id={headingId}
className="text-balance text-2xl font-semibold tracking-tight text-white sm:text-3xl"
>
{heading}
</h2>
) : null}
<div className={cn("flex flex-col gap-2.5", heading != null && "mt-8")}>
{items.map((item, index) => {
const open = openIndexes.includes(index);
const triggerId = `${baseId}-trigger-${index}`;
const panelId = `${baseId}-panel-${index}`;
return (
<motion.div
key={index}
initial={false}
animate={{
y: open ? -2 : 0,
scale: open ? 1.006 : 1,
borderColor: open ? "rgba(255,255,255,0.16)" : "rgba(255,255,255,0.08)",
backgroundColor: open ? "rgba(255,255,255,0.05)" : "rgba(255,255,255,0.02)",
boxShadow: open
? "0 12px 30px -14px rgba(0,0,0,0.7)"
: "0 0 0 0 rgba(0,0,0,0)",
}}
transition={liftTransition}
className="relative overflow-hidden rounded-xl border"
>
{/* Hairline accent seam down the open row's left edge. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: open ? 1 : 0 }}
transition={{ duration: reducedMotion ? 0 : 0.4, ease: "easeOut" }}
className="pointer-events-none absolute inset-y-2.5 left-0 w-px"
style={{
background:
"linear-gradient(to bottom, transparent, var(--fold-accent) 28%, var(--fold-accent) 72%, transparent)",
}}
/>
<h3 className="m-0">
<button
ref={(el) => {
triggerRefs.current[index] = el;
}}
type="button"
id={triggerId}
aria-expanded={open}
aria-controls={panelId}
onClick={() => toggle(index)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={cn(
"flex w-full items-center justify-between gap-4 px-5 py-4 text-left",
"text-[15px] font-medium text-white/90 transition-colors hover:text-white",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--fold-accent)] focus-visible:ring-inset"
)}
>
<span className="min-w-0">{item.question}</span>
<motion.svg
viewBox="0 0 16 16"
aria-hidden="true"
className="h-4 w-4 shrink-0"
initial={false}
animate={{
rotate: open ? 180 : 0,
color: open ? "var(--fold-accent)" : "#8a8a8a",
}}
transition={chevronTransition}
>
<path
d="M4 6l4 4 4-4"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</motion.svg>
</button>
</h3>
<AnimatePresence initial={false}>
{open ? (
<motion.div
key="panel"
id={panelId}
role="region"
aria-labelledby={triggerId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={panelTransition}
className="overflow-hidden"
style={{ perspective: 900 }}
>
<motion.div
initial={reducedMotion ? false : { rotateX: -14, opacity: 0 }}
animate={{ rotateX: 0, opacity: 1 }}
exit={reducedMotion ? undefined : { rotateX: -8, opacity: 0 }}
transition={
reducedMotion
? { duration: 0 }
: { duration: 0.34, ease: [0.16, 1, 0.3, 1] }
}
style={{ transformOrigin: "top center" }}
className="px-5 pb-4.5 pt-0 text-sm leading-relaxed text-neutral-400"
>
{item.answer}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
);
})}
</div>
</section>
);
}
Installation
CLI
npx shadcn@latest add @crucible/foldManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| items | FoldItem[] | five generic FAQs | Question/answer pairs, rendered in order. |
| heading | React.ReactNode | "Frequently asked questions" | Optional heading rendered as an h2 above the stack. Pass null to omit. |
| singleOpen | boolean | true | When true (default), opening a row folds the previously open one shut so only one answer shows at a time. When false, rows open independently. |
| defaultOpenIndex | number | 0 | Index open on mount. Pass -1 to start fully folded. |
| colors | FoldColors | { accent: "#93c5fd" } | Accent tokens — the single cool hue on the active chevron and ring. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"section">, "color"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.