Hovering over any word spawns a wake of card elements at the cursor position, each positioned absolutely with a random rotation (-6 to +6 degrees) and a scale-up entrance transition (cubic-bezier 0.18, 0.89, 0.32, 1.28). New cards appear only when the cursor has moved at least 80 px from the last spawn point, and the previous card fades out with a blur-and-shrink exit (380 ms). Card images are procedurally generated on a canvas -- gradient backgrounds from a five-colour palette with scattered radial accents and a large watermark letter -- then converted to data URLs, so there are no image assets.
Hoveroverthesewords
Hover to spawn a trailing wake of cards at your cursor
Source
"use client";
import { useEffect, useRef, useCallback } from "react";
import ScrambleLink from "../ScrambleLink";
// GRGR palette base colors and their generated card gradients
const PHOTOS = [
{ base: [237, 30, 38], accent: [252, 20, 29], label: "R" }, // Red
{ base: [61, 29, 11], accent: [231, 109, 41], label: "B" }, // Brown
{ base: [138, 177, 214], accent: [29, 144, 246], label: "Bl" },// Blue
{ base: [12, 6, 70], accent: [104, 89, 248], label: "N" }, // Navy
{ base: [216, 188, 93], accent: [219, 181, 52], label: "G" }, // Gold
];
const TRAIL_MIN_DIST = 80;
const TRAIL_MAX_CARDS = 12;
const TRAIL_STOP_DELAY = 90;
const TRAIL_FADE_MS = 380;
const TRAIL_OFFSET_X = 28;
const TRAIL_OFFSET_Y = 28;
function generateCardCanvas(
base: number[],
accent: number[],
label: string
): string {
const cvs = document.createElement("canvas");
cvs.width = 240;
cvs.height = 320;
const ctx = cvs.getContext("2d")!;
const [br, bg, bb] = base;
const [ar, ag, ab] = accent;
const g = ctx.createLinearGradient(0, 0, cvs.width, cvs.height);
g.addColorStop(0, `rgb(${Math.min(255, br + 80)},${Math.min(255, bg + 80)},${Math.min(255, bb + 80)})`);
g.addColorStop(0.5, `rgb(${br},${bg},${bb})`);
g.addColorStop(1, `rgb(${Math.max(0, br - 30)},${Math.max(0, bg - 30)},${Math.max(0, bb - 30)})`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, cvs.width, cvs.height);
for (let i = 0; i < 6; i++) {
const x = Math.random() * cvs.width;
const y = Math.random() * cvs.height;
const r = 30 + Math.random() * 60;
const mix = i / 5;
const mr = Math.round(br + (ar - br) * mix);
const mg = Math.round(bg + (ag - bg) * mix);
const mb = Math.round(bb + (ab - bb) * mix);
const cg = ctx.createRadialGradient(x, y, 0, x, y, r);
cg.addColorStop(0, `rgba(${mr},${mg},${mb},0.4)`);
cg.addColorStop(1, `rgba(${mr},${mg},${mb},0)`);
ctx.fillStyle = cg;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.font = "bold 80px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = `rgba(255,255,255,0.15)`;
ctx.fillText(label, cvs.width / 2, cvs.height / 2);
return cvs.toDataURL();
}
const WORDS = ["Hover", "over", "these", "words"];
export default function TrailText() {
const layerRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef({ x: 0, y: 0 });
const trailState = useRef({
active: false,
srcs: null as string[] | null,
idx: 0,
lastX: -9999,
lastY: -9999,
currentCard: null as HTMLDivElement | null,
stopTimer: null as ReturnType<typeof setTimeout> | null,
});
const generatedRef = useRef<string[]>([]);
const ensureGenerated = useCallback(() => {
if (generatedRef.current.length > 0) return;
generatedRef.current = PHOTOS.map((p) =>
generateCardCanvas(p.base, p.accent, p.label)
);
}, []);
const fadeCard = useCallback((card: HTMLDivElement) => {
card.classList.add("trail-fade");
setTimeout(() => card.remove(), TRAIL_FADE_MS + 50);
}, []);
const spawnCard = useCallback((x: number, y: number) => {
const s = trailState.current;
const layer = layerRef.current;
if (!s.srcs?.length || !layer) return;
const src = s.srcs[s.idx % s.srcs.length];
s.idx++;
const card = document.createElement("div");
card.className = "trail-card";
const rot = (Math.random() * 12 - 6).toFixed(1);
const w = 150 + Math.floor(Math.random() * 50);
card.style.cssText = `
position: absolute;
left: ${x + TRAIL_OFFSET_X}px;
top: ${y + TRAIL_OFFSET_Y}px;
width: ${w}px;
transform: rotate(${rot}deg) scale(0.7);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(15,17,22,0.20), 0 2px 6px rgba(15,17,22,0.10);
opacity: 0;
transition: opacity 220ms ease, transform 320ms cubic-bezier(0.18, 0.89, 0.32, 1.28), filter 420ms ease;
will-change: transform, opacity, filter;
`;
const img = document.createElement("img");
img.src = src;
img.alt = "";
img.draggable = false;
img.style.cssText = "display: block; width: 100%; height: auto; user-select: none;";
card.appendChild(img);
layer.appendChild(card);
void card.offsetWidth;
card.style.opacity = "1";
card.style.transform = `rotate(${rot}deg) scale(1)`;
if (s.currentCard) fadeCard(s.currentCard);
s.currentCard = card;
while (layer.children.length > TRAIL_MAX_CARDS) {
layer.firstElementChild?.remove();
}
s.lastX = x;
s.lastY = y;
}, [fadeCard]);
const startTrail = useCallback((srcs: string[]) => {
const s = trailState.current;
if (s.stopTimer != null) {
clearTimeout(s.stopTimer);
s.stopTimer = null;
return;
}
s.active = true;
s.srcs = srcs;
s.idx = 0;
s.lastX = -9999;
s.lastY = -9999;
const c = cursorRef.current;
if (c.x > 0 || c.y > 0) spawnCard(c.x, c.y);
}, [spawnCard]);
const stopTrail = useCallback(() => {
const s = trailState.current;
if (s.stopTimer != null) return;
s.stopTimer = setTimeout(() => {
s.stopTimer = null;
s.active = false;
s.srcs = null;
if (s.currentCard) {
fadeCard(s.currentCard);
s.currentCard = null;
}
}, TRAIL_STOP_DELAY);
}, [fadeCard]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
cursorRef.current = { x: e.clientX, y: e.clientY };
const s = trailState.current;
if (s.active && s.srcs) {
const dx = e.clientX - s.lastX;
const dy = e.clientY - s.lastY;
if (dx * dx + dy * dy >= TRAIL_MIN_DIST * TRAIL_MIN_DIST) {
spawnCard(e.clientX, e.clientY);
}
}
};
document.addEventListener("mousemove", onMove);
return () => document.removeEventListener("mousemove", onMove);
}, [spawnCard]);
return (
<div className="fixed inset-0 bg-[#f5f3f0] flex items-center justify-center select-none">
<style>{`
.trail-card.trail-fade {
opacity: 0 !important;
filter: blur(12px);
transform: scale(0.92) !important;
transition: opacity 380ms cubic-bezier(0.4, 0, 0.2, 1),
filter 380ms cubic-bezier(0.4, 0, 0.2, 1),
transform 380ms cubic-bezier(0.4, 0, 0.2, 1) !important;
}
`}</style>
<div
ref={layerRef}
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 180 }}
aria-hidden="true"
/>
<div className="text-center" style={{ zIndex: 10 }}>
<p
className="flex flex-wrap justify-center gap-x-[0.3em] leading-[1.15]"
style={{
fontFamily: "var(--font-flux), Georgia, serif",
fontSize: "clamp(32px, 6vw, 72px)",
fontVariationSettings: "'wght' 300, 'SRIF' 0",
color: "#1a1a2e",
}}
>
{WORDS.map((word, wi) => (
<span
key={wi}
className="inline-block cursor-pointer italic"
style={{
textDecorationLine: "underline",
textDecorationThickness: "1px",
textUnderlineOffset: "0.16em",
textDecorationColor: "currentColor",
transition: "text-decoration-color 280ms ease",
}}
onMouseEnter={() => {
ensureGenerated();
startTrail(generatedRef.current);
}}
onMouseLeave={stopTrail}
>
{word}
</span>
))}
</p>
<p
className="mt-8 text-foreground/30"
style={{
fontSize: "13px",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
letterSpacing: "0.04em",
}}
>
Hover to spawn a trailing wake of cards at your cursor
</p>
</div>
<div className="fixed bottom-6 left-6" style={{ zIndex: 10 }}>
<ScrambleLink
from="TRAIL"
to="← LAB"
href="/lab"
className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors"
style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }}
/>
</div>
</div>
);
}