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 motion

Add 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:

components/ui/tear-off-interface.tsx
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

PropTypeRequiredDefaultDescription
sheetReactNodeYes—Content on the top sheet.
revealedReactNodeYes—Content underneath.
heightnumberNo470Height of the stage in px.
onDetach() => voidNo—Called when the sheet tears off.
onReturn() => voidNo—Called when the sheet is put back.
classNamestringNo—Classes for the stage.

Best Practices

  1. Keep both notes short. The sheet is 230px tall and the curl looks best with some empty paper.
  2. Use it for small, delightful reveals (a hint, a coupon, an easter egg), never for content people need.
  3. 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.
  4. Reduced motion skips the curl and throw; the sheet moves aside in one step.