"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from "motion/react";
export type PaperFoldDirection = "vertical" | "horizontal";
export interface PaperFoldProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** The paper's content. Readable and interactive only when fully unfolded. */
children: React.ReactNode;
/** Number of accordion segments (2–12). */
folds?: number;
/** Fold / unfold duration in ms. */
duration?: number;
/** Fold along the height (vertical) or the width (horizontal). */
direction?: PaperFoldDirection;
/** Strength of the crease shading, 0–1. */
shading?: number;
/** Let the toggle be dragged to scrub the fold by hand. */
scrub?: boolean;
/** Toggle focus ring and grip colour. */
accent?: string;
/** Controlled state. */
expanded?: boolean;
/** Initial state (uncontrolled). */
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
/** Toggle label while folded. */
openLabel?: string;
/** Toggle label while unfolded. */
closeLabel?: string;
/** Classes for the paper surface (applied to every segment and to the unfolded content). */
paperClassName?: string;
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Constants -------------------------------- */
/** Fold angle of each segment when fully folded. Below 90° the zigzag stays faintly visible. */
const MAX_ANGLE = 80;
const RAD = Math.PI / 180;
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
const EASE = [0.65, 0, 0.35, 1] as const;
/* --------------------------------- Segment --------------------------------- */
interface SegmentProps {
index: number;
count: number;
/** Segment length along the fold axis, in px. */
size: number;
/** Content box: width and height of the unfolded paper. */
width: number;
height: number;
vertical: boolean;
shading: number;
progress: MotionValue<number>;
paperClassName: string;
children: React.ReactNode;
}
/**
* One accordion panel. Panels are nested so each one hinges on the far edge of the previous
* one: the first tilts by φ, every following one by ±2φ relative to its parent, which gives
* absolute angles alternating +φ / −φ — a zigzag.
*/
function Segment({ index, count, size, width, height, vertical, shading, progress, paperClassName, children }: SegmentProps) {
const sign = (index % 2 === 0 ? -1 : 1) * (vertical ? 1 : -1);
const factor = index === 0 ? 1 : 2;
const rotate = useTransform(progress, (p) => sign * factor * (1 - p) * MAX_ANGLE);
// Panels tilted away from the light (every other one) get the darker crease.
const shade = useTransform(progress, (p) => shading * Math.sin((1 - p) * MAX_ANGLE * RAD) * (index % 2 === 0 ? 0.45 : 0.9));
const gradient = vertical
? `linear-gradient(${index % 2 === 0 ? "to bottom" : "to top"}, rgba(0,0,0,0.05), rgba(0,0,0,0.75))`
: `linear-gradient(${index % 2 === 0 ? "to right" : "to left"}, rgba(0,0,0,0.05), rgba(0,0,0,0.75))`;
return (
<motion.div
className={index === 0 ? "absolute left-0 top-0" : vertical ? "absolute left-0 top-full" : "absolute left-full top-0"}
style={{
width: vertical ? width : size,
height: vertical ? size : height,
transformStyle: "preserve-3d",
transformOrigin: vertical ? "50% 0%" : "0% 50%",
rotateX: vertical ? rotate : 0,
rotateY: vertical ? 0 : rotate,
}}
>
<div className={`absolute inset-0 overflow-hidden ${paperClassName}`}>
<div
style={{
width,
height,
transform: vertical ? `translateY(${-index * size}px)` : `translateX(${-index * size}px)`,
}}
>
{children}
</div>
<motion.div className="absolute inset-0" style={{ background: gradient, opacity: shade }} />
</div>
{index + 1 < count && (
<Segment
index={index + 1}
count={count}
size={size}
width={width}
height={height}
vertical={vertical}
shading={shading}
progress={progress}
paperClassName={paperClassName}
>
{children}
</Segment>
)}
</motion.div>
);
}
/* -------------------------------- Component -------------------------------- */
/**
* A panel that folds and unfolds like a sheet of paper. While folding, the content is drawn as
* N clipped, aria-hidden copies on nested 3D segments (rotateX / rotateY) with crease shading;
* once fully unfolded, the single real copy is shown, crisp and interactive. A button with
* aria-expanded toggles it, and can be dragged to scrub the fold by hand.
*/
export function PaperFold({
children,
folds = 5,
duration = 900,
direction = "vertical",
shading = 0.5,
scrub = true,
accent = "#FF7448",
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
openLabel = "Unfold",
closeLabel = "Fold",
paperClassName = "bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100",
className = "",
style,
...props
}: PaperFoldProps) {
const reduceMotion = usePrefersReducedMotion();
const vertical = direction !== "horizontal";
const count = Math.max(2, Math.min(12, Math.round(folds)));
const contentId = React.useId();
const [internal, setInternal] = React.useState(defaultExpanded);
const expanded = expandedProp ?? internal;
const setExpanded = (next: boolean) => {
if (next === expanded) return;
if (expandedProp === undefined) setInternal(next);
onExpandedChange?.(next);
};
const progress = useMotionValue(expanded ? 1 : 0);
const [atOpen, setAtOpen] = React.useState(expanded);
useMotionValueEvent(progress, "change", (p) => setAtOpen(p >= 0.999));
const showReal = expanded && atOpen;
const rootRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const [box, setBox] = React.useState({ w: 0, h: 0 });
// Measure the available width (root) and the content's natural height.
React.useEffect(() => {
const root = rootRef.current;
const content = contentRef.current;
if (!root || !content) return;
const measure = () => {
const w = Math.round(root.getBoundingClientRect().width);
const h = Math.round(content.getBoundingClientRect().height);
setBox((b) => (b.w === w && b.h === h ? b : { w, h }));
};
const ro = new ResizeObserver(measure);
ro.observe(root);
ro.observe(content);
return () => ro.disconnect();
}, []);
// The paper's footprint shrinks with the cosine of the fold angle.
const extent = useMotionValue<number | string>("auto");
React.useEffect(() => {
const full = vertical ? box.h : box.w;
const update = (p: number) => extent.set(full ? full * Math.cos((1 - p) * MAX_ANGLE * RAD) : "auto");
update(progress.get());
return progress.on("change", update);
}, [box, vertical, progress, extent]);
// Animate toward the current state (skipped while the user is scrubbing).
const dragRef = React.useRef<{ id: number; start: number; from: number; moved: boolean } | null>(null);
const suppressClick = React.useRef(false);
React.useEffect(() => {
if (dragRef.current?.moved) return;
const controls = animate(progress, expanded ? 1 : 0, {
duration: reduceMotion ? 0 : Math.max(0, duration) / 1000,
ease: EASE,
});
return () => controls.stop();
}, [expanded, duration, reduceMotion, progress]);
const length = vertical ? box.h : box.w;
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!scrub || !length || e.button !== 0) return;
dragRef.current = { id: e.pointerId, start: vertical ? e.clientY : e.clientX, from: progress.get(), moved: false };
};
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current;
if (!drag || drag.id !== e.pointerId) return;
const delta = (vertical ? e.clientY : e.clientX) - drag.start;
if (!drag.moved && Math.abs(delta) < 5) return;
if (!drag.moved) {
drag.moved = true;
e.currentTarget.setPointerCapture(e.pointerId);
}
// Roughly track the paper edge: the folded footprint grows with the unfolded length.
progress.set(clamp01(drag.from + delta / (length * 0.85)));
};
const endDrag = (e: React.PointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current;
if (!drag || drag.id !== e.pointerId) return;
dragRef.current = null;
if (!drag.moved) return;
suppressClick.current = true;
const target = progress.get() > 0.5;
if (target !== expanded) setExpanded(target);
else
animate(progress, target ? 1 : 0, {
duration: reduceMotion ? 0 : (Math.max(0, duration) / 1000) * 0.5,
ease: EASE,
});
};
return (
<div
ref={rootRef}
className={`flex w-full flex-col ${vertical ? "items-stretch" : "items-start"} gap-3 ${className}`}
style={{ ["--pf-accent" as string]: accent, ...style }}
{...props}
>
<motion.div
className="relative"
style={{
width: vertical ? "100%" : extent,
height: vertical ? extent : box.h || "auto",
perspective: 1600,
perspectiveOrigin: vertical ? "50% 0%" : "0% 50%",
}}
>
<div
ref={contentRef}
id={contentId}
inert={!showReal}
className={`${showReal ? "relative" : "absolute left-0 top-0 invisible"} ${paperClassName}`}
style={{ width: box.w || "100%" }}
>
{children}
</div>
{!showReal && box.w > 0 && box.h > 0 && (
<div aria-hidden="true" inert className="pointer-events-none absolute inset-0" style={{ transformStyle: "preserve-3d" }}>
<Segment
index={0}
count={count}
size={length / count}
width={box.w}
height={box.h}
vertical={vertical}
shading={Math.min(1, Math.max(0, shading))}
progress={progress}
paperClassName={paperClassName}
>
{children}
</Segment>
</div>
)}
</motion.div>
<button
type="button"
aria-expanded={expanded}
aria-controls={contentId}
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
setExpanded(!expanded);
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={`inline-flex h-9 select-none items-center gap-2 self-start rounded-full border border-zinc-900/10 bg-white px-3.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-700 shadow-[0_1px_2px_rgba(0,0,0,0.05)] transition-colors hover:border-zinc-900/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--pf-accent)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25 ${scrub ? (vertical ? "cursor-ns-resize touch-none" : "cursor-ew-resize touch-none") : ""}`}
>
{scrub && (
<span aria-hidden="true" className={`flex gap-[3px] ${vertical ? "flex-col" : "flex-row"}`}>
<span className={`${vertical ? "h-px w-3" : "h-3 w-px"} bg-[var(--pf-accent)]`} />
<span className={`${vertical ? "h-px w-3" : "h-3 w-px"} bg-[var(--pf-accent)]`} />
</span>
)}
{expanded ? closeLabel : openLabel}
<svg
aria-hidden="true"
viewBox="0 0 12 12"
className={`h-3 w-3 transition-transform duration-300 motion-reduce:transition-none ${vertical ? (expanded ? "rotate-180" : "") : expanded ? "rotate-90" : "-rotate-90"}`}
fill="none"
stroke="currentColor"
strokeWidth="1.4"
>
<path d="M3 4.5 6 7.5 9 4.5" />
</svg>
</button>
</div>
);
}