"use client";
import { useEffect, useMemo, useRef } from "react";
import { useAnimationLoop, type Metrics } from "@/hooks/use-animation-loop";
export interface TearProps {
perforationAt?: number;
jag?: number;
teeth?: number;
resistance?: number;
gravity?: number;
autoReset?: boolean;
resetDelay?: number;
inkColor?: string;
onTear?: () => void;
reducedMotion?: boolean;
className?: string;
}
type Phase = "idle" | "dragging" | "springback" | "autotear" | "falling" | "gone";
const mulberry32 = (seed: number) => () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const buildJag = (teeth: number, jag: number): number[] => {
const rand = mulberry32(7);
const offsets: number[] = [0];
for (let i = 1; i < teeth; i++) offsets.push((rand() - 0.5) * 2 * jag * 6);
offsets.push(0);
return offsets;
};
const TicketFace = ({ ink, at }: { ink: string; at: number }) => (
<div className="absolute inset-0 overflow-hidden rounded-lg">
<div
className="absolute inset-y-0 left-0 flex flex-col justify-between p-4"
style={{ width: `${at * 100}%` }}
>
<div>
<p
className="font-display text-lg leading-none font-bold tracking-[0.08em]"
style={{ color: ink }}
>
ADMIT ONE
</p>
<p className="mt-1.5 font-mono text-[9px] tracking-[0.24em] text-[#8b87a0] uppercase">
Shadow Garden · Hall C
</p>
</div>
<p className="font-mono text-[10px] tracking-[0.18em] text-[#6e6a82]">
ROW 07 · SEAT 21
</p>
</div>
<div
className="absolute inset-y-0 border-l-2 border-dashed"
style={{
left: `${at * 100}%`,
borderColor: `color-mix(in oklab, ${ink} 40%, transparent)`,
}}
/>
<div
className="absolute inset-y-0 flex flex-col items-center justify-between py-4"
style={{ left: `${at * 100}%`, right: 0 }}
>
<p className="font-mono text-[9px] tracking-[0.2em] text-[#8b87a0]">No. 0921</p>
<div className="flex h-10 items-stretch gap-[3px] px-3">
{[3, 1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2].map((w, i) => (
<div key={i} style={{ width: w, background: ink, opacity: 0.85 }} />
))}
</div>
<p className="font-mono text-[9px] tracking-[0.3em] text-[#6e6a82]">STUB</p>
</div>
</div>
);
const Tear = ({
perforationAt = 0.62,
jag = 0.6,
teeth = 26,
resistance = 0.6,
gravity = 1,
autoReset = true,
resetDelay = 1800,
inkColor = "#c4b5fd",
onTear,
reducedMotion = false,
className = "",
}: TearProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const ticketRef = useRef<HTMLDivElement | null>(null);
const attachedRef = useRef<HTMLDivElement | null>(null);
const flapRef = useRef<HTMLDivElement | null>(null);
const jagOffsets = useMemo(() => buildJag(Math.round(teeth), jag), [teeth, jag]);
const live = useRef({
perforationAt,
resistance,
gravity,
autoReset,
resetDelay,
reducedMotion,
jagOffsets,
onTear,
});
live.current = {
perforationAt,
resistance,
gravity,
autoReset,
resetDelay,
reducedMotion,
jagOffsets,
onTear,
};
const sim = useRef({
phase: "idle" as Phase,
w: 0,
h: 0,
front: 0,
frontV: 0,
drive: 0,
startX: 0,
startY: 0,
samples: [] as Array<{ t: number; x: number; y: number }>,
px: 0,
py: 0,
vx: 0,
vy: 0,
rot: 0,
rotV: 0,
fallT: 0,
healLeft: 0,
lastAttached: "",
lastFlap: "",
});
const seamX = (f: number) => {
const s = sim.current;
const offs = live.current.jagOffsets;
const seam = s.w * live.current.perforationAt;
const scaled = f * (offs.length - 1);
const i = Math.min(offs.length - 2, Math.floor(scaled));
const frac = scaled - i;
return seam + offs[i] + (offs[i + 1] - offs[i]) * frac;
};
const applyClips = () => {
const s = sim.current;
const attached = attachedRef.current;
const flap = flapRef.current;
if (!attached || !flap || s.w <= 0) return;
const offs = live.current.jagOffsets;
const n = offs.length - 1;
const f = Math.min(Math.max(s.front, 0), 1);
const frontY = f * s.h;
const a: string[] = [`0px 0px`, `${seamX(0).toFixed(1)}px 0px`];
const kMax = Math.floor(f * n);
for (let k = 1; k <= kMax; k++) {
a.push(`${(s.w * live.current.perforationAt + offs[k]).toFixed(1)}px ${((k / n) * s.h).toFixed(1)}px`);
}
a.push(`${seamX(f).toFixed(1)}px ${frontY.toFixed(1)}px`);
a.push(`${s.w}px ${frontY.toFixed(1)}px`);
a.push(`${s.w}px ${s.h}px`, `0px ${s.h}px`);
const aClip = `polygon(${a.join(", ")})`;
if (aClip !== s.lastAttached) {
attached.style.clipPath = aClip;
s.lastAttached = aClip;
}
if (f <= 0.001 && s.phase !== "falling" && s.phase !== "gone") {
if (s.lastFlap !== "hidden") {
flap.style.opacity = "0";
s.lastFlap = "hidden";
}
return;
}
const b: string[] = [`${seamX(0).toFixed(1)}px 0px`, `${s.w}px 0px`];
b.push(`${s.w}px ${frontY.toFixed(1)}px`);
b.push(`${seamX(f).toFixed(1)}px ${frontY.toFixed(1)}px`);
for (let k = kMax; k >= 1; k--) {
b.push(`${(s.w * live.current.perforationAt + offs[k]).toFixed(1)}px ${((k / n) * s.h).toFixed(1)}px`);
}
const bClip = `polygon(${b.join(", ")})`;
if (bClip !== s.lastFlap) {
flap.style.clipPath = bClip;
flap.style.opacity = "1";
s.lastFlap = bClip;
}
};
const applyFlapTransform = (opacity = 1) => {
const s = sim.current;
const flap = flapRef.current;
if (!flap) return;
const f = Math.min(Math.max(s.front, 0), 1);
const originX = seamX(f);
const originY = f * s.h;
flap.style.transformOrigin = `${originX}px ${originY}px`;
if (s.phase === "falling" || s.phase === "gone") {
flap.style.transform = `translate3d(${s.px.toFixed(1)}px, ${s.py.toFixed(1)}px, 0) rotate(${s.rot.toFixed(2)}deg)`;
flap.style.opacity = String(Math.max(opacity, 0));
} else {
const swing = f * 13;
flap.style.transform = `translate3d(${(f * 5).toFixed(1)}px, 0px, 0) rotate(${swing.toFixed(2)}deg)`;
}
};
const measureRef = useRef<((m: Metrics) => void) | null>(null);
const loop = useAnimationLoop({
target: containerRef,
halted: false,
onResize: (m) => measureRef.current?.(m),
onFrame: ({ dt }) => {
const s = sim.current;
const cfg = live.current;
if (s.w <= 0) return false;
if (s.phase === "idle") {
applyClips();
return false;
}
if (s.phase === "dragging") {
applyClips();
applyFlapTransform();
return;
}
if (s.phase === "springback") {
if (cfg.reducedMotion) {
s.front = 0;
s.frontV = 0;
} else {
s.frontV += (-260 * s.front - 22 * s.frontV) * dt;
s.front += s.frontV * dt;
}
if (s.front <= 0.002 && Math.abs(s.frontV) < 0.02) {
s.front = 0;
s.frontV = 0;
s.phase = "idle";
}
applyClips();
applyFlapTransform();
return;
}
if (s.phase === "autotear") {
s.front = Math.min(1, s.front + dt * 1.6);
applyClips();
applyFlapTransform();
if (s.front >= 1) release(0, 120);
return;
}
if (s.phase === "falling") {
s.fallT += dt;
s.vy += 1400 * cfg.gravity * dt;
s.px += s.vx * dt;
s.py += s.vy * dt;
s.rot += s.rotV * dt;
const fade = Math.min(Math.max((s.fallT - 0.3) / 0.45, 0), 1);
applyFlapTransform(1 - fade);
if (fade >= 1 || s.py > s.h * 3) {
s.phase = "gone";
s.healLeft = cfg.resetDelay / 1000;
}
return;
}
if (!cfg.autoReset) return false;
s.healLeft -= dt;
if (s.healLeft <= 0) {
s.front = 0;
s.frontV = 0;
s.px = 0;
s.py = 0;
s.rot = 0;
s.fallT = 0;
s.phase = "idle";
const flap = flapRef.current;
if (flap) {
flap.style.transform = "";
flap.style.opacity = "0";
s.lastFlap = "hidden";
}
applyClips();
return false;
}
},
});
const release = (vx: number, vy: number) => {
const s = sim.current;
const cfg = live.current;
s.phase = "falling";
s.fallT = 0;
s.px = 0;
s.py = 0;
s.vx = vx * 0.5;
s.vy = Math.max(vy * 0.5, 60);
s.rotV = vx * 0.12 + 40;
cfg.onTear?.();
if (cfg.reducedMotion) {
s.phase = "gone";
s.healLeft = cfg.resetDelay / 1000;
const flap = flapRef.current;
if (flap) {
flap.style.opacity = "0";
s.lastFlap = "hidden";
}
}
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const s = sim.current;
if (s.phase === "falling" || s.phase === "gone" || s.phase === "autotear") return;
e.currentTarget.setPointerCapture(e.pointerId);
s.phase = "dragging";
s.startX = e.clientX;
s.startY = e.clientY;
s.samples = [{ t: e.timeStamp, x: e.clientX, y: e.clientY }];
loop.start();
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const s = sim.current;
const cfg = live.current;
if (s.phase !== "dragging") return;
const dx = e.clientX - s.startX;
const dy = e.clientY - s.startY;
s.drive = Math.max(0, dy + Math.max(dx, 0) * 0.5);
const u = Math.min(s.drive / (s.h * 1.15), 1);
s.front = Math.pow(u, 1 + cfg.resistance * 1.6);
if (cfg.reducedMotion && u >= 0.35) {
s.front = 1;
release(0, 80);
return;
}
if (s.front >= 1) {
const first = s.samples[0];
const lastS = s.samples[s.samples.length - 1];
const span = lastS && first ? lastS.t - first.t : 0;
release(
span > 8 ? ((lastS.x - first.x) / span) * 1000 : 0,
span > 8 ? ((lastS.y - first.y) / span) * 1000 : 0,
);
return;
}
s.samples.push({ t: e.timeStamp, x: e.clientX, y: e.clientY });
if (s.samples.length > 4) s.samples.shift();
loop.start();
};
const onPointerUp = () => {
const s = sim.current;
if (s.phase !== "dragging") return;
s.phase = s.front > 0 ? "springback" : "idle";
s.drive = 0;
loop.start();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const s = sim.current;
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
if (s.phase !== "idle") return;
if (live.current.reducedMotion) {
s.front = 1;
release(0, 80);
} else {
s.phase = "autotear";
}
loop.start();
};
useEffect(() => {
measureRef.current = () => {
const ticket = ticketRef.current;
if (!ticket) return;
const rect = ticket.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const s = sim.current;
s.w = rect.width;
s.h = rect.height;
s.lastAttached = "";
s.lastFlap = "";
applyClips();
applyFlapTransform();
};
loop.resize();
return () => {
measureRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loop]);
useEffect(() => {
const s = sim.current;
s.lastAttached = "";
s.lastFlap = "";
loop.paint();
}, [jagOffsets, perforationAt, loop]);
useEffect(() => {
loop.start();
}, [autoReset, loop]);
return (
<div
ref={containerRef}
className={`flex h-full w-full touch-none items-center justify-center select-none ${className}`.trim()}
>
<div ref={ticketRef} className="relative aspect-[2.15/1] w-[min(86%,360px)]">
<div
ref={attachedRef}
className="absolute inset-0 rounded-lg border border-[#33333e] bg-[#17151d] shadow-[0_10px_30px_rgba(0,0,0,0.4)]"
>
<TicketFace ink={inkColor} at={perforationAt} />
</div>
<div
ref={flapRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-lg border border-[#33333e] bg-[#17151d] opacity-0 will-change-transform"
>
<TicketFace ink={inkColor} at={perforationAt} />
</div>
<div
role="button"
tabIndex={0}
aria-label="Tear off the ticket stub"
className="absolute inset-y-0 right-0 cursor-grab rounded-r-lg outline-none focus-visible:ring-2 focus-visible:ring-amethyst active:cursor-grabbing"
style={{ left: `${perforationAt * 100}%` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
/>
</div>
</div>
);
};
export default Tear;