# 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.

A shadcn/ui block from the [@crucible](https://crucibleui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/crucible/fold).

- Page, with a live preview: https://shadcn.exchange/r/crucible/fold
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/crucible/fold-light-823f5193b4d07ef2.png
- Registry item: https://crucibleui.com/r/fold.json
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/crucible/fold.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://crucibleui.com/r/use-reduced-motion.json`

## Files

### `registry/default/sections/fold/fold.tsx`

```tsx
"use client";

import { AnimatePresence, motion, type Transition } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/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 (changing it later re-seeds the open row). 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] : []
  );

  // Re-seed the open row when `defaultOpenIndex` itself changes after mount
  // (e.g. a live customizer knob), using the adjust-state-during-render pattern.
  const [seededIndex, setSeededIndex] = React.useState(defaultOpenIndex);
  if (seededIndex !== defaultOpenIndex) {
    setSeededIndex(defaultOpenIndex);
    setOpenIndexes(
      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>
  );
}
```
