NEMAWASHI LAB

Dissolve

Canvas

Deleting an image attachment disintegrates it into dust. The thumbnail is chopped into a grid of cells (default 3px); a sweep front starting at the close-button corner launches each cell with a staggered delay, and every cell emits 1--4 colour-sampled dust motes that drift on a wind vector, wobble sinusoidally (turbulence), brighten toward white, and fade out. The intact remainder is the full-res image drawn clipped to the cells that have not yet launched, so the dissolve edge looks like crumbling pixels, not a feathered mask. The + button re-materialises the last deleted image by running the same animation in reverse. Vanilla Canvas 2D, no libraries.

Leather
Red fabric
Green fabric
Water

Which of these should make the winter drop?

Dissolve

Canvas · hover an image, click ✕ · + re-adds

Controls
Cell3
Dust2
Sweep480
Drift X160
Drift Y-220
Turbulence1.00
Scatter1.00

Source

Dissolve.tsx521 lines
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelToggle } from "../_kansei/controls";

// ---------------------------------------------------------------------------
// Dissolve — deleting should feel as good as adding. Clicking ✕ on an image
// attachment disintegrates it into dust that sweeps away on the wind; the +
// button reassembles the last deleted image from the same dust in reverse.
// After Nils Eller.
//
// How it reads as "real": during the sweep the intact remainder is the actual
// full-res thumbnail, drawn clipped to the cells that haven't launched yet —
// so the image stays crisp and the dissolve front is a blocky, crumbling
// pixel edge. Each launched cell emits a few 1–2px dust motes that brighten
// toward white as they fly. Vanilla Canvas 2D, no libraries.
// ---------------------------------------------------------------------------

/* the material swatches the fold's bird already ships with — the mockup
   product shots this was first built against never made it into the repo */
const IMAGES = [
  { src: "/fold/leather.jpg", alt: "Leather" },
  { src: "/fold/fabric-red.jpg", alt: "Red fabric" },
  { src: "/fold/fabric-green.jpg", alt: "Green fabric" },
  { src: "/fold/water.jpg", alt: "Water" },
];

const THUMB = 88;
const THUMB_GAP = 12;
const THUMB_RADIUS = 12;
const THUMB_BG = "#edeae6";
const COLLAPSE_MS = 320;

type Phase = "absent" | "in" | "dissolving" | "collapsing" | "growing" | "materializing";

interface Dust {
  x: number;
  y: number;
  size: number;
  r: number;
  g: number;
  b: number;
  delay: number;
  life: number;
  tx: number;
  ty: number;
  perpX: number;
  perpY: number;
  wobAmp: number;
  wobFreq: number;
  phase: number;
}

interface Cell {
  x: number;
  y: number;
  delay: number;
}

interface Anim {
  index: number;
  mode: "delete" | "add";
  rect: { left: number; top: number; w: number; h: number };
  off: HTMLCanvasElement;
  cellSize: number;
  cells: Cell[];
  dust: Dust[];
  total: number;
  clock: number;
  last: number;
}

function smoothstep(a: number, b: number, x: number): number {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
}

// Cheap approximately-normal random in [-1.5, 1.5].
function randn(): number {
  return Math.random() + Math.random() + Math.random() - 1.5;
}

export default function Dissolve() {
  const [phases, setPhases] = useState<Phase[]>(() => IMAGES.map(() => "in"));
  const [cell, setCell] = useState(3);
  const [dustCount, setDustCount] = useState(2);
  const [sweep, setSweep] = useState(480);
  const [driftX, setDriftX] = useState(160);
  const [driftY, setDriftY] = useState(-220);
  const [turbulence, setTurbulence] = useState(1);
  const [scatter, setScatter] = useState(1);
  const [slowmo, setSlowmo] = useState(false);

  const canvasRef = useRef<HTMLCanvasElement>(null);
  const thumbRefs = useRef<(HTMLDivElement | null)[]>([]);
  const imgRefs = useRef<(HTMLImageElement | null)[]>([]);
  const animsRef = useRef<Anim[]>([]);
  const rafRef = useRef<number | null>(null);
  const removedRef = useRef<number[]>([]);
  const slowmoRef = useRef(false);
  slowmoRef.current = slowmo;
  const paramsRef = useRef({ cell, dustCount, sweep, driftX, driftY, turbulence, scatter });
  paramsRef.current = { cell, dustCount, sweep, driftX, driftY, turbulence, scatter };
  const finishRef = useRef<(anim: Anim) => void>(() => {});

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    // Size to the canvas's own CSS box — the viewport standalone, the demo
    // card when contained by the detail-page frame's transform trick.
    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = canvas.offsetWidth * dpr;
      canvas.height = canvas.offsetHeight * dpr;
      canvas.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
    return () => ro.disconnect();
  }, []);

  useEffect(() => {
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  // Draw one animation at absolute time t (ms).
  const drawAnim = useCallback((ctx: CanvasRenderingContext2D, anim: Anim, t: number) => {
    const { rect, cellSize } = anim;

    // Intact remainder: the real image clipped to unlaunched (delete) /
    // locked (add) cells — crisp interior, crumbling pixel edge.
    const path = new Path2D();
    let remaining = 0;
    for (const c of anim.cells) {
      if (c.delay > t) {
        path.rect(rect.left + c.x, rect.top + c.y, cellSize, cellSize);
        remaining++;
      }
    }
    if (remaining > 0) {
      ctx.save();
      ctx.clip(path);
      ctx.drawImage(anim.off, rect.left, rect.top, rect.w, rect.h);
      ctx.restore();
    }

    // Airborne dust — brightens toward white and fades as it travels.
    for (const p of anim.dust) {
      const local = t - p.delay;
      if (local <= 0 || local >= p.life) continue;
      const s = local / p.life;
      const e = 1 - (1 - s) * (1 - s) * (1 - s);
      const wob = Math.sin(s * p.wobFreq + p.phase) * p.wobAmp * s;
      const x = p.x + p.tx * e + p.perpX * wob;
      const y = p.y + p.ty * e + p.perpY * wob;
      const k = 0.65 * s;
      const alpha = 1 - smoothstep(0.3, 1, s);
      ctx.globalAlpha = alpha;
      ctx.fillStyle = `rgb(${Math.round(p.r + (255 - p.r) * k)},${Math.round(p.g + (255 - p.g) * k)},${Math.round(p.b + (255 - p.b) * k)})`;
      const size = p.size * (1 - 0.35 * s);
      ctx.fillRect(x - size / 2, y - size / 2, size, size);
    }
    ctx.globalAlpha = 1;
  }, []);

  // rAF loop only runs while an animation is in flight — idle frames skipped.
  const tick = useCallback(
    (now: number) => {
      rafRef.current = null;
      const canvas = canvasRef.current;
      const ctx = canvas?.getContext("2d");
      if (!canvas || !ctx) return;
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      const anims = animsRef.current;
      const finished: Anim[] = [];
      for (const anim of anims) {
        anim.clock += (now - anim.last) * (slowmoRef.current ? 0.25 : 1);
        anim.last = now;
        const t = anim.mode === "delete" ? anim.clock : anim.total - anim.clock;
        drawAnim(ctx, anim, t);
        if (anim.clock >= anim.total) finished.push(anim);
      }
      animsRef.current = anims.filter((a) => !finished.includes(a));
      for (const anim of finished) finishRef.current(anim);

      if (animsRef.current.length > 0) {
        rafRef.current = requestAnimationFrame(tick);
      } else {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
      }
    },
    [drawAnim]
  );

  const ensureLoop = useCallback(() => {
    if (rafRef.current == null) rafRef.current = requestAnimationFrame(tick);
  }, [tick]);

  // Snapshot the rendered thumbnail (full-res offscreen copy + colour grid).
  const buildAnim = useCallback((index: number, mode: "delete" | "add"): Anim | null => {
    const holder = thumbRefs.current[index];
    const img = imgRefs.current[index];
    const canvas = canvasRef.current;
    if (!holder || !img || !canvas || !img.complete || img.naturalWidth === 0) return null;

    // Canvas-relative coords: standalone the canvas origin is the viewport's,
    // but inside the detail-page frame it is the demo card's top-left.
    const canvasRect = canvas.getBoundingClientRect();
    const viewportRect = holder.getBoundingClientRect();
    const rect = {
      left: viewportRect.left - canvasRect.left,
      top: viewportRect.top - canvasRect.top,
      width: viewportRect.width,
      height: viewportRect.height,
    };
    const w = Math.round(rect.width);
    const h = Math.round(rect.height);
    if (w === 0 || h === 0) return null;

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const off = document.createElement("canvas");
    off.width = w * dpr;
    off.height = h * dpr;
    const octx = off.getContext("2d");
    if (!octx) return null;
    octx.scale(dpr, dpr);
    octx.beginPath();
    octx.roundRect(0, 0, w, h, THUMB_RADIUS);
    octx.clip();
    octx.fillStyle = THUMB_BG;
    octx.fillRect(0, 0, w, h);
    // object-fit: cover
    const ir = img.naturalWidth / img.naturalHeight;
    const r = w / h;
    let sx = 0, sy = 0, sw = img.naturalWidth, sh = img.naturalHeight;
    if (ir > r) {
      sw = sh * r;
      sx = (img.naturalWidth - sw) / 2;
    } else {
      sh = sw / r;
      sy = (img.naturalHeight - sh) / 2;
    }
    octx.drawImage(img, sx, sy, sw, sh, 0, 0, w, h);

    // 1x copy for colour sampling.
    const sample = document.createElement("canvas");
    sample.width = w;
    sample.height = h;
    const sctx = sample.getContext("2d");
    if (!sctx) return null;
    sctx.drawImage(off, 0, 0, w, h);
    const data = sctx.getImageData(0, 0, w, h).data;

    const p = paramsRef.current;
    const size = Math.max(2, Math.round(p.cell));
    const driftMag = Math.hypot(p.driftX, p.driftY) || 1;
    const perpX = -p.driftY / driftMag;
    const perpY = p.driftX / driftMag;

    const cells: Cell[] = [];
    const dust: Dust[] = [];
    for (let cy = 0; cy + size <= h; cy += size) {
      for (let cx = 0; cx + size <= w; cx += size) {
        const px = Math.min(w - 1, cx + (size >> 1));
        const py = Math.min(h - 1, cy + (size >> 1));
        const i = (py * w + px) * 4;
        if (data[i + 3] < 8) continue;
        // Sweep front starts at the top-right corner, where ✕ lives.
        const d = ((w - px) / w + py / h) / 2;
        const delay = d * p.sweep * (0.85 + 0.3 * Math.random());
        cells.push({ x: cx, y: cy, delay });
        for (let n = 0; n < p.dustCount; n++) {
          const mag = 0.6 + 0.8 * Math.random();
          dust.push({
            x: rect.left + cx + Math.random() * size,
            y: rect.top + cy + Math.random() * size,
            size: 1 + Math.random() * (size * 0.6),
            r: data[i],
            g: data[i + 1],
            b: data[i + 2],
            delay: delay + Math.random() * 90,
            life: 550 + 400 * Math.random(),
            tx: p.driftX * mag + p.scatter * randn() * 70,
            ty: p.driftY * mag + p.scatter * randn() * 70,
            perpX,
            perpY,
            wobAmp: p.turbulence * (8 + 22 * Math.random()),
            wobFreq: 4 + 6 * Math.random(),
            phase: Math.random() * Math.PI * 2,
          });
        }
      }
    }

    let total = 0;
    for (const q of dust) total = Math.max(total, q.delay + q.life);
    return {
      index,
      mode,
      rect: { left: rect.left, top: rect.top, w, h },
      off,
      cellSize: size,
      cells,
      dust,
      total,
      clock: 0,
      last: performance.now(),
    };
  }, []);

  const setPhase = useCallback((index: number, phase: Phase) => {
    setPhases((prev) => prev.map((p, i) => (i === index ? phase : p)));
  }, []);

  finishRef.current = (anim: Anim) => {
    if (anim.mode === "delete") {
      setPhase(anim.index, "collapsing");
      window.setTimeout(() => setPhase(anim.index, "absent"), COLLAPSE_MS + 40);
    } else {
      setPhase(anim.index, "in");
    }
  };

  const startAnim = useCallback(
    (index: number, mode: "delete" | "add") => {
      const anim = buildAnim(index, mode);
      if (!anim) return false;
      animsRef.current.push(anim);
      // Paint the first frame synchronously so hiding the <img> never flashes.
      const ctx = canvasRef.current?.getContext("2d");
      if (ctx) drawAnim(ctx, anim, mode === "delete" ? 0 : anim.total);
      ensureLoop();
      return true;
    },
    [buildAnim, drawAnim, ensureLoop]
  );

  const handleDelete = useCallback(
    (index: number) => {
      if (phases[index] !== "in") return;
      removedRef.current.push(index);
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !startAnim(index, "delete")) {
        setPhase(index, "collapsing");
        window.setTimeout(() => setPhase(index, "absent"), COLLAPSE_MS + 40);
        return;
      }
      setPhase(index, "dissolving");
    },
    [phases, setPhase, startAnim]
  );

  const handleAdd = useCallback(() => {
    // Peek, don't pop — a click while the deletion is still dissolving must
    // not silently discard the image from the undo stack.
    const index = removedRef.current[removedRef.current.length - 1];
    if (index == null || phases[index] !== "absent") return;
    removedRef.current.pop();
    setPhase(index, "growing");
    // Let the slot finish widening before sampling its final rect.
    window.setTimeout(() => {
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !startAnim(index, "add")) {
        setPhase(index, "in");
        return;
      }
      setPhase(index, "materializing");
    }, COLLAPSE_MS + 40);
  }, [phases, setPhase, startAnim]);

  const anyRemoved = phases.some((p) => p === "absent");

  return (
    <div className="fixed inset-0 overflow-hidden bg-[#f5f5f4] font-[family-name:var(--font-geist-sans)]">
      {/* Chat input card */}
      <div className="absolute inset-0 flex items-center justify-center">
        <div className="w-[min(620px,88vw)] rounded-[20px] bg-white border border-black/[0.06] shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_40px_-12px_rgba(0,0,0,0.08)] px-6 pt-6 pb-5">
          {/* Attachments */}
          <div className="flex">
            {IMAGES.map((image, i) => {
              const phase = phases[i];
              if (phase === "absent") return null;
              const collapsed = phase === "collapsing";
              const growing = phase === "growing" || phase === "materializing";
              return (
                <div
                  key={image.src}
                  className="relative overflow-visible"
                  style={{
                    width: collapsed ? 0 : THUMB + THUMB_GAP,
                    transition: `width ${COLLAPSE_MS}ms cubic-bezier(0.4,0,0.2,1)`,
                    ...(growing ? { animation: `dissolve-grow ${COLLAPSE_MS}ms cubic-bezier(0.4,0,0.2,1) both` } : {}),
                  }}
                >
                  <div
                    ref={(el) => { thumbRefs.current[i] = el; }}
                    className="group relative"
                    style={{ width: THUMB, height: THUMB }}
                  >
                    {/* eslint-disable-next-line @next/next/no-img-element */}
                    <img
                      ref={(el) => { imgRefs.current[i] = el; }}
                      src={image.src}
                      alt={image.alt}
                      width={THUMB}
                      height={THUMB}
                      className="w-full h-full object-cover"
                      style={{
                        backgroundColor: THUMB_BG,
                        borderRadius: THUMB_RADIUS,
                        visibility: phase === "in" ? "visible" : "hidden",
                      }}
                      draggable={false}
                    />
                    {phase === "in" && (
                      <button
                        onClick={() => handleDelete(i)}
                        aria-label={`Remove ${image.alt}`}
                        className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-neutral-800 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 flex items-center justify-center cursor-pointer"
                      >
                        <svg width="8" height="8" viewBox="0 0 8 8" fill="none">
                          <path d="M1 1L7 7M7 1L1 7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
                        </svg>
                      </button>
                    )}
                  </div>
                </div>
              );
            })}
          </div>

          {/* Prompt */}
          <p className="mt-4 text-[17px] leading-snug text-neutral-800">
            Which of these should make the winter drop?
          </p>

          {/* Toolbar */}
          <div className="mt-5 flex items-center">
            <button
              onClick={handleAdd}
              disabled={!anyRemoved}
              aria-label="Re-add last deleted image"
              className={`w-8 h-8 -ml-1 flex items-center justify-center rounded-lg transition-colors ${
                anyRemoved
                  ? "text-neutral-500 hover:text-neutral-800 hover:bg-neutral-100 cursor-pointer"
                  : "text-neutral-300 cursor-default"
              }`}
            >
              <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
                <path d="M8 2.5V13.5M2.5 8H13.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
              </svg>
            </button>
            <div className="w-8 h-8 flex items-center justify-center text-neutral-400">
              <svg width="15" height="15" viewBox="0 0 15 15" fill="none">
                <path d="M3.5 2V7M3.5 10.5V13M7.5 2V4M7.5 7.5V13M11.5 2V9M11.5 12.5V13" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
                <circle cx="3.5" cy="8.75" r="1.4" stroke="currentColor" strokeWidth="1.2" />
                <circle cx="7.5" cy="5.75" r="1.4" stroke="currentColor" strokeWidth="1.2" />
                <circle cx="11.5" cy="10.75" r="1.4" stroke="currentColor" strokeWidth="1.2" />
              </svg>
            </div>
            <div className="flex-1" />
            <button
              aria-label="Send"
              className="w-11 h-10 rounded-[12px] bg-[#2c50ee] hover:bg-[#2343d6] transition-colors flex items-center justify-center text-white cursor-pointer"
            >
              <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
                <path d="M2.5 8H13M13 8L8.5 3.5M13 8L8.5 12.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
          </div>
        </div>
      </div>

      {/* Particle overlay — above the card, never intercepts input */}
      {/* w/h-full matters: a canvas is a replaced element, so inset-0 alone
          would leave it at its intrinsic (DPR-scaled) size. 100% (not 100vw)
          so it matches the containing block when framed in the detail page. */}
      <canvas ref={canvasRef} className="fixed inset-0 z-30 w-full h-full pointer-events-none" />

      <div
        className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <p className="text-[12px] tracking-[0.08em] uppercase text-foreground/50">Dissolve</p>
        <p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">Canvas · hover an image, click ✕ · + re-adds</p>
      </div>

      <DraggablePanel
        isLight={true}
        controls={[
          { label: "Cell", value: cell, set: (v) => setCell(Math.round(v)), min: 2, max: 8, step: 1 },
          { label: "Dust", value: dustCount, set: (v) => setDustCount(Math.round(v)), min: 1, max: 4, step: 1 },
          { label: "Sweep", value: sweep, set: setSweep, min: 100, max: 1200, step: 10 },
          { label: "Drift X", value: driftX, set: setDriftX, min: -400, max: 400, step: 10 },
          { label: "Drift Y", value: driftY, set: setDriftY, min: -400, max: 400, step: 10 },
          { label: "Turbulence", value: turbulence, set: setTurbulence, min: 0, max: 2, step: 0.05 },
          { label: "Scatter", value: scatter, set: setScatter, min: 0, max: 2, step: 0.05 },
        ]}
      >
        <PanelToggle label="Slow-mo" value={slowmo} set={setSlowmo} isLight={true} />
      </DraggablePanel>

      <div className="fixed top-8 left-8 z-10">
        <ScrambleLink
          from="NEMAWASHI LAB"
          to="← BACK"
          href="/lab"
          className="text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] text-foreground/60"
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>

      <style>{`@keyframes dissolve-grow { from { width: 0; } to { width: ${THUMB + THUMB_GAP}px; } }`}</style>
    </div>
  );
}

NEMAWASHI — Kotaro Abe