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.




Which of these should make the winter drop?
Dissolve
Canvas · hover an image, click ✕ · + re-adds
Source
"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>
);
}