"use client"; import { useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"; type TornPaperRevealProps = { src: string; alt: string; /** Printed on the sheet. */ label?: string; /** Colour of the sheet. */ paper?: string; /** Colour of the ink on the sheet. */ ink?: string; /** How much of the sheet is already torn at rest, 0 to 1. */ start?: number; /** Past this much, letting go tears the rest off. */ finish?: number; className?: string; }; const STEPS = 56; const GONE = 1.2; const FLAP_PAD = 20; /* The edge changes as it travels, so it reads as new paper ripping, not one shape sliding. */ function jag(y: number, p: number) { return Math.sin(y * 17 + p * 9) * 1.5 + Math.sin(y * 41 - p * 13 + 1.3) * 0.9 + Math.sin(y * 97 + p * 23 + 0.7) * 0.55 + Math.sin(y * 233 + 2) * 0.35; } const edgeAt = (y: number, p: number) => -8 + 118 * p + (0.5 - y) * 11 + jag(y, p); const fibre = (y: number, p: number) => 1.1 + 0.9 * (Math.sin(y * 29 + p * 11) + 1) + 0.5 * Math.abs(Math.sin(y * 151)); const curl = (y: number, p: number) => Math.min(13, 70 * Math.max(0, p - 0.02)) * (1 - 0.38 * (2 * y - 1) ** 2) * (1 + 0.1 * Math.sin(y * 13 + p * 5)); function points(at: (y: number) => number) { return Array.from({ length: STEPS + 1 }, (_, i) => { const y = i / STEPS; return `${at(y).toFixed(2)}% ${(y * 104 - 2).toFixed(2)}%`; }); } const sheetClip = (p: number) => `polygon(${points((y) => edgeAt(y, p)).join()},130% 102%,130% -2%)`; const fibreClip = (p: number) => `polygon(${points((y) => edgeAt(y, p) - fibre(y, p)).join()},130% 102%,130% -2%)`; /* The flap sits at a fixed size and moves with translate, so its shading stays on the fold. */ function flapClip(p: number) { const base = edgeAt(0.5, p); const forward = points((y) => edgeAt(y, p) - base + FLAP_PAD); const back = points((y) => edgeAt(y, p) - base + FLAP_PAD + curl(y, p)).reverse(); return `polygon(${[...forward, ...back].join()})`; } /* TornPaperReveal: a kraft sheet pasted over the picture, already ripped at one side. Drag to tear it across, or press to rip it all off; press again to paste a new sheet. */ export function TornPaperReveal({ src, alt, label = "Open here", paper = "#b98d5c", ink = "#3b2412", start = 0.17, finish = 0.62, className = "" }: TornPaperRevealProps) { const reduce = useReducedMotion(); const box = useRef(null); const grip = useRef<{ x: number; from: number } | null>(null); const dragged = useRef(false); const [gone, setGone] = useState(false); const p = useMotionValue(start); const fresh = useMotionValue(1); const pasted = useTransform(fresh, [0, 1], [1.08, 1]); const sheet = useTransform(p, sheetClip); const fibres = useTransform(p, fibreClip); const flap = useTransform(p, flapClip); const flapX = useTransform(p, (v) => `${edgeAt(0.5, v)}%`); const flapOn = useTransform(p, [0.98, 1.1], [1, 0]); function ripAway() { setGone(true); if (reduce) p.set(GONE); else animate(p, GONE, { duration: 0.32 + (GONE - p.get()) * 0.5, ease: [0.3, 0.1, 0.2, 1] }); } function paste() { setGone(false); p.set(start); fresh.set(0); if (reduce) fresh.set(1); else animate(fresh, 1, { duration: 0.34, ease: "easeOut" }); } function tearTo(v: number) { if (!reduce) animate(p, v, { duration: 0.18, ease: "easeOut" }); else p.set(v); } function down(e: PointerEvent) { if (gone) return; e.currentTarget.setPointerCapture(e.pointerId); grip.current = { x: e.clientX, from: p.get() }; dragged.current = false; } function move(e: PointerEvent) { if (!grip.current) return; const dx = (e.clientX - grip.current.x) / box.current!.clientWidth; if (Math.abs(dx) > 0.015) dragged.current = true; if (dragged.current) p.set(Math.max(p.get(), Math.min(GONE, grip.current.from + dx))); } function up() { if (!grip.current) return; grip.current = null; if (dragged.current && p.get() >= finish) ripAway(); } function click() { if (dragged.current) { dragged.current = false; return; } if (gone) paste(); else ripAway(); } function keys(e: KeyboardEvent) { if (gone || (e.key !== "ArrowRight" && e.key !== "ArrowLeft")) return; e.preventDefault(); if (e.key === "ArrowLeft") return; const to = p.get() + 0.12; if (to >= finish) ripAway(); else tearTo(to); } return (
); }