Image Effects
TornPaperReveal
A kraft sheet pasted over the picture, already ripped at one side with white paper fibres and a curled flap. Drag to tear it across, press to rip it all off, press again to paste a new sheet. A tear never heals.
Download
- React
- Tailwind CSS v4
- motion
components/ui/torn-paper-reveal.tsx
"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<HTMLButtonElement>(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<HTMLButtonElement>) {
if (gone) return;
e.currentTarget.setPointerCapture(e.pointerId);
grip.current = { x: e.clientX, from: p.get() };
dragged.current = false;
}
function move(e: PointerEvent<HTMLButtonElement>) {
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 (
<div className={`@container w-96 ${className}`}>
<button
ref={box}
type="button"
aria-pressed={gone}
aria-label={gone ? `${alt}. Paste a new sheet over it` : `Picture under a paper sheet: ${alt}. Press to rip the paper off, or use the right arrow to tear it bit by bit`}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onPointerEnter={() => !gone && !grip.current && p.get() < start + 0.04 && tearTo(start + 0.04)}
onPointerLeave={() => !gone && !grip.current && p.get() <= start + 0.041 && tearTo(start)}
onKeyDown={keys}
onClick={click}
className={`relative block aspect-[4/3] w-full touch-pan-y select-none overflow-hidden text-[4cqw] outline-none shadow-[0_0.6em_1.2em_-0.6em_rgba(0,0,0,0.7)] focus-visible:ring-[0.18em] focus-visible:ring-white ${gone ? "cursor-pointer" : "cursor-grab active:cursor-grabbing"}`}
>
<img src={src} alt="" draggable={false} className="absolute inset-0 size-full object-cover" />
<motion.span aria-hidden style={{ opacity: fresh, scale: pasted }} className="absolute inset-0 block">
<span className="absolute inset-0 block [filter:drop-shadow(-0.1em_0.05em_0.14em_rgba(0,0,0,0.55))]">
<motion.span style={{ clipPath: fibres }} className="absolute inset-0 block bg-[#f6f0e2]" />
<motion.span
style={{ clipPath: sheet, backgroundColor: paper, color: ink }}
className="absolute inset-0 block overflow-hidden bg-[repeating-linear-gradient(91deg,rgba(60,30,10,0.09)_0_0.04em,transparent_0.04em_0.22em),repeating-linear-gradient(2deg,rgba(255,235,200,0.06)_0_0.03em,transparent_0.03em_0.31em)]"
>
<span className="absolute inset-[0.7em] block border-[0.09em] border-dashed border-current opacity-40" />
<span className="absolute inset-0 grid -rotate-4 place-items-center pl-[1.6em] text-center font-black uppercase leading-[0.9] tracking-tight opacity-80 [font-family:Impact,'Arial_Narrow',sans-serif]">
<span className="max-w-[80%] text-[2.5em]">{label}</span>
</span>
</motion.span>
</span>
<motion.span style={{ opacity: flapOn }} className="absolute inset-0 block [filter:drop-shadow(0.16em_0.08em_0.16em_rgba(40,20,5,0.6))]">
<motion.span
style={{ clipPath: flap, x: flapX }}
className="absolute inset-y-0 left-[-20%] block w-full bg-[linear-gradient(98deg,#b9ab90_18%,#efe6d3_22%,#f7f0e1_26%,#d9cdb5_30%,#a8987a_33%)]"
/>
</motion.span>
</motion.span>
</button>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/torn-paper-reveal.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/torn-paper-reveal-demo.tsx
import { TornPaperReveal } from "@/components/ui/torn-paper-reveal"; export default function TornPaperRevealExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#3b3a38] p-2"> <TornPaperReveal src="/demo/moss.jpg" alt="Green hills in soft light" label="Fresh print inside" className="w-[min(116cqh,90cqw,480px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| src | string | — | Picture address. |
| alt | string | — | Text for screen readers. |
| label | string | "Open here" | Printed on the sheet. |
| paper | string | "#b98d5c" | Colour of the sheet. |
| ink | string | "#3b2412" | Colour of the ink on the sheet. |
| start | number | 0.17 | How much is already torn at rest, 0 to 1. |
| finish | number | 0.62 | Past this much, letting go tears the rest off. |
| className | string | "w-96" | Set the width here. The piece stays 4 by 3. |