Overview
A note that hides another one underneath. Grab the top-right corner and the paper curls back: the fold follows your pointer, the back of the sheet shows with soft shading, a shadow falls across the page, and the note underneath sharpens as more of it shows. Let go early and the corner settles back; pull far enough and the sheet tears away. Once torn, it's a loose card you can drag and throw (it sways with its speed), pin in place, or put back with a button, a double-click or Escape. The corner also responds on hover, and Enter on the corner plays the whole peel.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { useCallback, useEffect, useRef, useState } from "react";import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";import { AnimatePresence, animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform, useVelocity,} from "motion/react";import { cn } from "@/lib/utils"; export type TearOffInterfaceProps = { /** Content printed on the sheet that can be torn away. */ sheet: ReactNode; /** Content revealed beneath the sheet. */ revealed: ReactNode; /** Height of the stage in px. */ height?: number; className?: string; onDetach?: () => void; onReturn?: () => void;}; type Pt = { x: number; y: number };type Phase = "attached" | "peeling" | "tearing" | "torn" | "returning"; const SHEET_H = 230;const REST: Pt = { x: -16, y: 16 }; // resting curl, relative to the top-right cornerconst HOVER: Pt = { x: -38, y: 38 };const SPRING = { type: "spring" as const, stiffness: 260, damping: 24 }; /** Keep the part of a convex polygon where (p - m)·n <= 0. */function clipHalf(poly: Pt[], m: Pt, n: Pt): Pt[] { const side = (p: Pt) => (p.x - m.x) * n.x + (p.y - m.y) * n.y; const out: Pt[] = []; for (let i = 0; i < poly.length; i++) { const a = poly[i]; const b = poly[(i + 1) % poly.length]; const sa = side(a); const sb = side(b); if (sa <= 0) out.push(a); if ((sa < 0 && sb > 0) || (sa > 0 && sb < 0)) { const t = sa / (sa - sb); out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }); } } return out;} const poly = (pts: Pt[]) => (pts.length < 3 ? "polygon(0 0, 0 0, 0 0)" : `polygon(${pts.map((p) => `${p.x.toFixed(1)}px ${p.y.toFixed(1)}px`).join(", ")})`); /** The visible sheet, the folded-over flap, and the fold's geometry for a corner dragged to p. */function curl(w: number, h: number, p: Pt) { const c = { x: w, y: 0 }; const dx = c.x - p.x; const dy = c.y - p.y; const len = Math.hypot(dx, dy) || 1; const n = { x: dx / len, y: dy / len }; const m = { x: (c.x + p.x) / 2, y: (c.y + p.y) / 2 }; const rect = [ { x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }, ]; const sheet = clipHalf(rect, m, n); // The flap is the cut-away corner, mirrored across the fold. const away = clipHalf(rect, m, { x: -n.x, y: -n.y }); const flap = away.map((q) => { const d = (q.x - m.x) * n.x + (q.y - m.y) * n.y; return { x: q.x - 2 * d * n.x, y: q.y - 2 * d * n.y }; }); const angle = (Math.atan2(n.y, n.x) * 180) / Math.PI; return { sheet, flap, m, angle, len };} export function TearOffInterface({ sheet, revealed, height = 470, className, onDetach, onReturn }: TearOffInterfaceProps) { const reduce = !!useReducedMotion(); const stageRef = useRef<HTMLDivElement>(null); const sheetRef = useRef<HTMLDivElement>(null); const [w, setW] = useState(300); const [phase, setPhase] = useState<Phase>("attached"); const [pinned, setPinned] = useState(false); const phaseRef = useRef<Phase>("attached"); const go = (p: Phase) => { phaseRef.current = p; setPhase(p); }; useEffect(() => { const el = sheetRef.current; if (!el) return; const ro = new ResizeObserver(([e]) => setW(e.contentRect.width)); ro.observe(el); return () => ro.disconnect(); }, []); // Corner position relative to the top-right corner (0,0 = flat). const cx = useMotionValue(reduce ? 0 : REST.x); const cy = useMotionValue(reduce ? 0 : REST.y); const [geo, setGeo] = useState(() => curl(w, SHEET_H, { x: w + REST.x, y: REST.y })); const update = useCallback(() => setGeo(curl(w, SHEET_H, { x: w + cx.get(), y: cy.get() })), [w, cx, cy]); useMotionValueEvent(cx, "change", update); useMotionValueEvent(cy, "change", update); useEffect(update, [update]); const diag = Math.hypot(w, SHEET_H); const progress = Math.min(1, geo.len / (diag * 0.9)); const tearAt = Math.min(w, SHEET_H) * 0.95; const to = (p: Pt, opts: object = SPRING) => { animate(cx, p.x, opts); animate(cy, p.y, opts); }; // Free card once torn. const fx = useMotionValue(0); const fy = useMotionValue(0); const fr = useMotionValue(0); const vx = useVelocity(fx); const sway = useSpring(useTransform(vx, [-1500, 0, 1500], [-14, 0, 14]), { stiffness: 300, damping: 30 }); const rotate = useTransform(() => fr.get() + sway.get()); const tear = (dir: Pt) => { go("tearing"); onDetach?.(); const len = Math.hypot(dir.x, dir.y) || 1; const ux = dir.x / len; const uy = dir.y / len; if (reduce) { cx.jump(0); cy.jump(0); fx.jump(-w * 0.1); fy.jump(SHEET_H * 0.77); fr.jump(-4); go("torn"); return; } // Finish the peel along the pull, then drop the free sheet where it lands. const far = { x: cx.get() + ux * diag * 1.6, y: cy.get() + uy * diag * 1.6 }; animate(cx, far.x, { duration: 0.28, ease: [0.4, 0, 1, 1] }); animate(cy, far.y, { duration: 0.28, ease: [0.4, 0, 1, 1], onComplete: () => { fx.jump(ux * 120); fy.jump(uy * 60 + 40); fr.jump(ux * 18); go("torn"); animate(fx, -w * 0.1, { type: "spring", stiffness: 160, damping: 18 }); animate(fy, SHEET_H * 0.77, { type: "spring", stiffness: 160, damping: 18 }); animate(fr, -4, { type: "spring", stiffness: 160, damping: 14 }); cx.jump(0); cy.jump(0); }, }); }; const restore = () => { setPinned(false); go("returning"); onReturn?.(); const done = () => { fx.jump(0); fy.jump(0); fr.jump(0); cx.jump(0); cy.jump(0); go("attached"); if (!reduce) to(REST); requestAnimationFrame(() => cornerRef.current?.focus()); }; if (reduce) return done(); animate(fr, 0, SPRING); animate(fx, 0, SPRING); animate(fy, 0, { ...SPRING, onComplete: done }); }; // Corner drag. const cornerRef = useRef<HTMLButtonElement>(null); const grab = useRef<{ id: number; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null>(null); const onDown = (e: ReactPointerEvent<HTMLButtonElement>) => { if (phaseRef.current !== "attached" || (e.pointerType === "mouse" && e.button !== 0)) return; grab.current = { id: e.pointerId, sx: e.clientX, sy: e.clientY, ox: cx.get(), oy: cy.get(), moved: false }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} }; const onMove = (e: ReactPointerEvent<HTMLButtonElement>) => { const g = grab.current; if (!g || g.id !== e.pointerId) return; const dx = e.clientX - g.sx; const dy = e.clientY - g.sy; if (!g.moved && Math.hypot(dx, dy) < 3) return; if (!g.moved) { g.moved = true; go("peeling"); } // The corner can't be pushed up or right of where it started. cx.set(Math.min(0, g.ox + dx)); cy.set(Math.max(0, g.oy + dy)); }; const onUp = (e: ReactPointerEvent<HTMLButtonElement>) => { const g = grab.current; if (!g || g.id !== e.pointerId) return; grab.current = null; if (!g.moved) return; const dist = Math.hypot(cx.get(), cy.get()); if (dist > tearAt) tear({ x: cx.get(), y: cy.get() }); else { go("attached"); to(REST); } }; const onCornerKey = (e: ReactKeyboardEvent<HTMLButtonElement>) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (reduce) return tear({ x: -1, y: 1 }); // A keyboard peel plays the whole gesture. go("peeling"); animate(cx, -w * 0.7, { duration: 0.45, ease: [0.65, 0, 0.35, 1] }); animate(cy, SHEET_H * 0.7, { duration: 0.45, ease: [0.65, 0, 0.35, 1], onComplete: () => tear({ x: -1, y: 1 }) }); } }; const attached = phase === "attached" || phase === "peeling" || phase === "tearing"; const torn = phase === "torn" || phase === "returning"; const reveal = torn ? 1 : progress; const shadeAlpha = Math.min(0.28, 0.08 + progress * 0.4); const [flatHover, setFlatHover] = useState(false); return ( <div ref={stageRef} className={cn( "relative isolate mx-auto w-full max-w-[440px] overflow-hidden rounded-[22px] bg-(--color-paper-2) select-none", "shadow-[0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_20px_48px_-28px_rgba(0,0,0,0.3)]", className, )} style={{ height }} onKeyDown={(e) => { if (e.key === "Escape" && phaseRef.current === "torn") restore(); }} > <div aria-hidden className="pointer-events-none absolute inset-0 [background-image:radial-gradient(var(--color-rule)_0.8px,transparent_0.8px)] [background-size:14px_14px] opacity-60" /> <div ref={sheetRef} className="absolute top-12 left-1/2 w-[min(300px,calc(100%-48px))] -translate-x-1/2" style={{ height: SHEET_H }}> {/* Revealed note: comes into focus as the sheet lifts. */} <motion.div className="absolute inset-0 rounded-[14px] bg-(--color-paper) p-5 text-(--color-ink) shadow-[0_0_0_1px_var(--color-rule),0_4px_16px_-6px_rgba(0,0,0,0.12)]" style={{ opacity: reduce ? 1 : 0.35 + reveal * 0.65, filter: reduce || reveal > 0.95 ? "none" : `blur(${((1 - reveal) * 4).toFixed(2)}px)`, }} aria-hidden={attached && phase !== "tearing"} > <div className="mb-3 flex items-center justify-between border-b border-dashed border-(--color-rule) pb-2 font-mono text-[10px] tracking-[0.14em] text-(--color-neutral) uppercase"> <span>Underneath</span> <span>02 / 02</span> </div> <div className="text-[14px] leading-relaxed">{revealed}</div> </motion.div> {attached ? ( <> {/* Shadow cast by the lifted corner */} <div aria-hidden className="pointer-events-none absolute inset-0 rounded-[14px]" style={{ clipPath: poly(geo.sheet), background: `linear-gradient(${geo.angle + 90}deg, transparent 0, transparent calc(50% - 1px), rgba(0,0,0,${shadeAlpha}) 50%, transparent calc(50% + 28px))`, }} /> {/* Visible part of the sheet */} <div className="absolute inset-0 overflow-hidden rounded-[14px] bg-(--color-paper) text-(--color-ink) shadow-[0_0_0_1px_var(--color-rule),0_6px_18px_-8px_rgba(0,0,0,0.18)]" style={{ clipPath: reduce ? undefined : poly(geo.sheet) }} > <SheetFace>{sheet}</SheetFace> <div aria-hidden className="pointer-events-none absolute inset-0" style={{ background: `linear-gradient(${geo.angle - 90}deg, transparent ${Math.max(0, 100 - progress * 140)}%, rgba(0,0,0,${(shadeAlpha * 0.6).toFixed(3)}) 100%)`, }} /> </div> {/* The back of the paper, folded over */} {!reduce ? ( <div aria-hidden className="pointer-events-none absolute inset-0" style={{ filter: `drop-shadow(0 ${4 + progress * 10}px ${6 + progress * 14}px rgba(0,0,0,${0.12 + progress * 0.18}))` }}> <div className="absolute inset-0" style={{ clipPath: poly(geo.flap), background: `linear-gradient(${geo.angle + 180}deg, color-mix(in oklch, var(--color-paper-2) 70%, var(--color-ink) 6%) 0%, var(--color-paper) 45%, color-mix(in oklch, var(--color-paper) 85%, white) 100%)`, }} /> </div> ) : null} {/* Corner grip */} <button ref={cornerRef} type="button" aria-label="Peel the sheet to see what's underneath" onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} onPointerEnter={() => { setFlatHover(true); if (phaseRef.current === "attached" && !reduce) to(HOVER); }} onPointerLeave={() => { setFlatHover(false); if (phaseRef.current === "attached" && !grab.current && !reduce) to(REST); }} onFocus={() => phaseRef.current === "attached" && !reduce && to(HOVER)} onBlur={() => phaseRef.current === "attached" && !reduce && to(REST)} onKeyDown={onCornerKey} onClick={(e) => { if (e.detail === 0 && reduce) tear({ x: -1, y: 1 }); }} className="absolute -top-3 -right-3 z-20 size-20 touch-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) cursor-grab active:cursor-grabbing" /> </> ) : null} {/* Torn-off sheet */} <AnimatePresence> {torn ? ( <motion.div key="free" drag={!pinned && phase === "torn" && !reduce} dragConstraints={stageRef} dragElastic={0.12} dragTransition={{ power: 0.25, timeConstant: 260 }} whileDrag={{ scale: 1.03, cursor: "grabbing" }} onDoubleClick={restore} style={{ x: fx, y: fy, rotate: reduce ? 0 : rotate }} className={cn( "absolute inset-0 z-30 rounded-[14px] bg-(--color-paper) text-(--color-ink)", pinned ? "shadow-[0_0_0_1px_var(--color-rule),0_4px_10px_-6px_rgba(0,0,0,0.25)]" : "cursor-grab shadow-[0_0_0_1px_var(--color-rule),0_22px_44px_-18px_rgba(0,0,0,0.45)]", )} > <SheetFace torn>{sheet}</SheetFace> <AnimatePresence> {pinned ? ( <motion.span aria-hidden initial={{ y: -24, opacity: 0, scale: 1.4 }} animate={{ y: 0, opacity: 1, scale: 1 }} exit={{ y: -18, opacity: 0 }} transition={{ type: "spring", bounce: 0.5, duration: 0.45 }} className="absolute -top-2 left-1/2 size-4 -translate-x-1/2 rounded-full bg-(--color-ink) shadow-[0_3px_6px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.35)]" /> ) : null} </AnimatePresence> <div className="absolute right-3 bottom-3 flex gap-1.5"> <SheetButton label={pinned ? "Unpin sheet" : "Pin sheet"} pressed={pinned} onClick={() => setPinned((p) => !p)}> <path d="M9.5 2.5 13.5 6.5M11 4l-3.2 3.2-3.3.6L9.2 12.5l.6-3.3L13 6M7 9l-4.5 4.5" /> </SheetButton> <SheetButton label="Put the sheet back" onClick={restore}> <path d="M3 8a5 5 0 1 0 1.5-3.6M3 2.5v3h3" /> </SheetButton> </div> </motion.div> ) : null} </AnimatePresence> </div> {/* Hint */} <div className="absolute inset-x-0 top-3 z-40 flex justify-center"> <AnimatePresence initial={false} mode="popLayout"> <motion.div key={torn ? (pinned ? "pinned" : "torn") : phase === "peeling" ? (Math.hypot(cx.get(), cy.get()) > tearAt ? "release" : "peel") : flatHover ? "grab" : "idle"} initial={{ opacity: 0, y: 6, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -6, filter: "blur(4px)" }} transition={{ duration: 0.2 }} className="rounded-full bg-(--color-paper)/80 px-3 py-1 font-mono text-[10.5px] tracking-[0.1em] text-(--color-neutral) uppercase shadow-[0_0_0_1px_var(--color-rule)] backdrop-blur-md" > {torn ? pinned ? "Pinned in place" : "Drag it around · double-click to put back" : phase === "peeling" ? Math.hypot(cx.get(), cy.get()) > tearAt ? "Let go to tear" : "Keep pulling" : flatHover ? "Drag the corner" : "Peel from the top-right corner"} </motion.div> </AnimatePresence> </div> <div className="sr-only" aria-live="polite"> {torn ? "Sheet torn off. The note underneath is visible." : ""} </div> </div> );} function SheetFace({ children, torn = false }: { children: ReactNode; torn?: boolean }) { return ( <div className="relative h-full p-5"> <div className="mb-3 flex items-center justify-between border-b border-(--color-rule) pb-2 pr-8 font-mono text-[10px] tracking-[0.14em] text-(--color-neutral) uppercase"> <span>Top sheet</span> <span>01 / 02</span> </div> <div className="pr-2 text-[14px] leading-relaxed">{children}</div> {/* Perforated top edge once torn */} {torn ? ( <div aria-hidden className="absolute inset-x-3 top-0 h-1.5 [background-image:radial-gradient(circle,var(--color-paper-2)_1.6px,transparent_1.8px)] [background-size:8px_6px] [background-position:0_-3px]" /> ) : null} </div> );} function SheetButton({ label, pressed, onClick, children }: { label: string; pressed?: boolean; onClick: () => void; children: ReactNode }) { return ( <button type="button" aria-label={label} aria-pressed={pressed} onPointerDown={(e) => e.stopPropagation()} onClick={onClick} className={cn( "grid size-8 place-items-center rounded-full outline-none transition-[background-color,color,transform] active:scale-90 focus-visible:ring-2 focus-visible:ring-(--color-focus)", pressed ? "bg-(--color-ink) text-(--color-paper)" : "bg-(--color-paper-2) text-(--color-ink-2) shadow-[inset_0_0_0_1px_var(--color-rule)] hover:text-(--color-ink)", )} > <svg aria-hidden viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"> {children} </svg> </button> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { TearOffInterface } from "@/components/ui/tear-off-interface"; <TearOffInterface sheet={<Note title="A little discovery" body="Pull the corner." />} revealed={<Note title="There it is." body="The sheet is still yours." />} onDetach={() => track("peeled")}/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| sheet | ReactNode | Yes | — | Content on the top sheet. |
| revealed | ReactNode | Yes | — | Content underneath. |
| height | number | No | 470 | Height of the stage in px. |
| onDetach | () => void | No | — | Called when the sheet tears off. |
| onReturn | () => void | No | — | Called when the sheet is put back. |
| className | string | No | — | Classes for the stage. |
Best Practices
- Keep both notes short. The sheet is 230px tall and the curl looks best with some empty paper.
- Use it for small, delightful reveals (a hint, a coupon, an easter egg), never for content people need.
- The corner is a real button: Enter peels the sheet, and once it's torn, the pin and return buttons and Escape work from the keyboard.
- Reduced motion skips the curl and throw; the sheet moves aside in one step.