NEMAWASHI LAB

Trail

Canvas

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

TrailText.tsx267 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe