File dropzone as a thermal field: dragging spawns rising accent convection wisps, accepted drops buoy off the drop point and squash-land into a docked queue rack, rejected files sink and fade.
npx shadcn add https://design.helpmarq.com /r/file-upload-thermal.jsonregistry/core/file-upload-thermal/component.tsx"use client";
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type DragEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
// ---------------------------------------------------------------------------
// UpdraftDropzone — file dropzone as a thermal field. Dragging a file over
// the zone spawns rising accent convection wisps on a canvas 2D layer (over
// the control, behind the copy). Accepted drops buoy upward off the drop
// point as DOM chips with damped sway, then squash-land into a docked queue
// rack; rejected files sink 40px and fade. Accept vs reject is felt
// physically, instantly. Direct-DOM rAF that sleeps when every chip is
// docked and every wisp is dead; wisp ink is derived from CSS tokens at
// mount and re-derived live on documentElement class changes.
// ---------------------------------------------------------------------------
// wisps
const SPAWN_RATE = 18; // wisps per second while dragging over
const WISP_CAP = 30; // max live wisps
const WISP_RISE_MIN = 30; // px/s
const WISP_RISE_MAX = 60; // px/s
const WISP_WOBBLE_AMP = 6; // px
const WISP_WOBBLE_PERIOD = 1.2; // s
const WISP_LIFE_MIN = 1.5; // s
const WISP_LIFE_MAX = 2.5; // s
const WISP_ALPHA = 0.12; // peak alpha, from --accent
// accepted-chip flight
const BUOYANCY = 900; // px/s^2 upward boost
const BUOY_T = 0.18; // boost window s
const FLY_K = 70; // spring pull toward dock slot s^-2
const FLY_ZETA = 0.95; // linear drag (near-critical)
const SWAY_AMP = 10; // px
const SWAY_HZ = 2;
const SWAY_DECAY = 6.28; // zeta~0.5 envelope at 2 Hz: exp(-zeta*omega*t)
const SETTLE_DEADLINE = 1.4; // forced-settle per chip s
const STAGGER_MS = 90; // multi-drop launch stagger
// landing squash
const SQUASH_S = 0.88;
const SQUASH_HOLD = 0.12; // s at full squash
const SQUASH_K = 300; // s^-2
const SQUASH_ZETA = 0.6;
// rejected-chip sink
const SINK_G = 600; // px/s^2
const SINK_DIST = 40; // px
const SINK_FADE = 0.5; // s
type Vec3 = readonly [number, number, number];
interface Wisp {
x: number;
y: number;
vy: number;
phase: number;
len: number;
life: number;
age: number;
}
interface Flight {
el: HTMLElement;
ox: number;
oy: number;
vx: number;
vy: number;
t: number; // negative during stagger wait
phase: "fly" | "squash";
squashT: number;
s: number;
sv: number;
}
interface SinkState {
id: string;
vy: number;
dy: number;
age: number;
}
export interface UpdraftFile {
id: string;
name: string;
size: number;
type: string;
}
interface RejectChip {
id: string;
name: string;
x: number;
y: number;
}
function parseColor(raw: string): Vec3 | null {
const s = raw.trim();
if (s.startsWith("#")) {
const hex = s.slice(1);
if (hex.length === 3) {
const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);
const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);
const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
if (hex.length >= 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
return null;
}
const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
// non-hex/rgb tokens (oklch(), hsl(), lab(), color(), named colors, …) —
// let the browser normalize the value instead of regexing every possible
// CSS color syntax: write it into a throwaway element and read back
// getComputedStyle(...).color, which browsers always resolve to rgb()/rgba().
if (typeof document === "undefined") return null;
const probe = document.createElement("span");
probe.style.color = s;
if (!probe.style.color) return null; // invalid value, not just an unhandled format
document.body.appendChild(probe);
const resolved = getComputedStyle(probe).color;
document.body.removeChild(probe);
const m2 = resolved.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
return m2 ? [Number(m2[1]), Number(m2[2]), Number(m2[3])] : null;
}
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
return `${bytes} B`;
}
function acceptSummary(accept: string[]): string {
if (accept.length === 0) return "Any file type";
return accept
.map((a) => (a.startsWith(".") ? a.slice(1).toUpperCase() : a))
.join(", ");
}
function typeIconPath(name: string, type: string): ReactNode {
const lower = name.toLowerCase();
if (type.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/.test(lower)) {
return (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.5-3.5a2 2 0 0 0-3 0L6 20" />
</>
);
}
if (type === "application/pdf" || /\.(pdf|docx?|txt|md|log)$/.test(lower)) {
return (
<>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
<path d="M9 13h6M9 17h4" />
</>
);
}
return (
<>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
</>
);
}
export function UpdraftDropzone({
defaultFiles = [],
accept = [],
maxSizeBytes = 8 * 1024 * 1024,
onFilesChange,
className = "",
"aria-label": ariaLabel = "Upload files",
}: {
/** files shown pre-docked in the queue rack */
defaultFiles?: { name: string; size: number; type: string }[];
/** allowed types: extensions (".png") or mime ("image/png", "image/*"); empty = any */
accept?: string[];
maxSizeBytes?: number;
onFilesChange?: (files: UpdraftFile[]) => void;
className?: string;
"aria-label"?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const chipEls = useRef(new Map<string, HTMLLIElement>());
const idSeq = useRef(0);
const dragDepth = useRef(0);
const reducedRef = useRef(false);
const pendingRef = useRef<
{ id: string; cx: number; cy: number; delay: number }[]
>([]);
const engineRef = useRef<{
setDrag: (on: boolean) => void;
dragPoint: (clientX: number) => void;
fly: (el: HTMLElement, cx: number, cy: number, delayMs: number) => void;
sink: (el: HTMLElement, id: string) => void;
} | null>(null);
const [files, setFiles] = useState<UpdraftFile[]>(() =>
defaultFiles.map((f, i) => ({ ...f, id: `uf-d${i}` }))
);
const [rejects, setRejects] = useState<RejectChip[]>([]);
const [isOver, setIsOver] = useState(false);
const [announce, setAnnounce] = useState("");
const hintId = useId();
const liveId = useId();
// -- engine: wisp canvas + chip physics, direct-DOM only ------------------
useEffect(() => {
const root = rootRef.current;
const zone = zoneRef.current;
const canvas = canvasRef.current;
if (!root || !zone || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
reducedRef.current = reduced;
// token-derived ink: read at mount, re-derived on theme class change
let accentInk: Vec3 = [0, 107, 255];
const derive = () => {
const cs = getComputedStyle(document.documentElement);
accentInk = parseColor(cs.getPropertyValue("--accent")) ?? accentInk;
};
derive();
// hot-path state: locals only, never React state
let w = 0;
let h = 0;
let dpr = 1;
let sized = false;
let raf = 0;
let last = 0;
let visible = true;
let dragging = false;
let dragLeft = 0; // cached zone rect left while dragging
let pointerX = -1; // canvas-local, -1 = unknown
let spawnAcc = 0;
let inkOn = false; // canvas has pixels that need a final clear
const wisps: Wisp[] = [];
const flights = new Map<HTMLElement, Flight>();
const sinks = new Map<HTMLElement, SinkState>();
const finishSink = (id: string) =>
setRejects((rs) => rs.filter((r) => r.id !== id));
const clearCanvas = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
};
const resize = () => {
const rect = zone.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) {
sized = false; // zero-size guard — never spawn into nothing
return;
}
w = rect.width;
h = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
// canvas is a replaced element — inset alone does not size it
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
sized = true;
clearCanvas();
};
resize();
const settleChip = (f: Flight) => {
f.el.style.transform = "";
f.el.style.transformOrigin = "";
f.el.style.opacity = "";
f.el.style.zIndex = "";
f.el.style.willChange = "";
flights.delete(f.el);
};
const loop = (now: number) => {
raf = 0;
if (!visible || document.hidden) {
last = 0;
return; // paused offscreen/hidden — wake() resumes
}
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
last = now;
let active = false;
// -- wisps: pruned array, full clear + redraw every frame -------------
if (sized) {
if (dragging) {
spawnAcc += SPAWN_RATE * dt;
while (spawnAcc >= 1) {
spawnAcc -= 1;
if (wisps.length >= WISP_CAP) continue;
// bias spawn toward the dragged pointer's column
const cx = pointerX >= 0 ? pointerX : w / 2;
const x =
Math.random() < 0.7
? Math.min(w - 2, Math.max(2, cx + (Math.random() - 0.5) * 140))
: Math.random() * w;
wisps.push({
x,
y: h - 2 - Math.random() * 10,
vy: -(WISP_RISE_MIN + Math.random() * (WISP_RISE_MAX - WISP_RISE_MIN)),
phase: Math.random() * Math.PI * 2,
len: 10 + Math.random() * 8,
life: WISP_LIFE_MIN + Math.random() * (WISP_LIFE_MAX - WISP_LIFE_MIN),
age: 0,
});
}
active = true;
}
if (wisps.length > 0) {
clearCanvas();
inkOn = true;
ctx.lineCap = "round";
ctx.lineWidth = 1.5;
for (let i = wisps.length - 1; i >= 0; i--) {
const wp = wisps[i];
if (!wp) continue;
wp.age += dt;
wp.y += wp.vy * dt;
if (wp.age >= wp.life || wp.y + wp.len < 0) {
wisps.splice(i, 1);
continue;
}
const xd =
wp.x +
WISP_WOBBLE_AMP *
Math.sin((wp.age / WISP_WOBBLE_PERIOD) * Math.PI * 2 + wp.phase);
const a = WISP_ALPHA * Math.sin((Math.PI * wp.age) / wp.life);
ctx.strokeStyle = `rgba(${accentInk[0]},${accentInk[1]},${accentInk[2]},${a.toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(xd, wp.y);
ctx.quadraticCurveTo(xd + 3, wp.y + wp.len * 0.5, xd, wp.y + wp.len);
ctx.stroke();
}
active = true;
} else if (inkOn) {
clearCanvas(); // one explicit final clear, then the layer is inert
inkOn = false;
}
}
// -- accepted-chip flights: OFFSET transforms toward the dock slot ----
for (const f of flights.values()) {
if (!f.el.isConnected) {
flights.delete(f.el);
continue;
}
active = true;
f.t += dt;
if (f.t < 0) continue; // stagger wait, chip hidden at drop offset
if (f.phase === "fly") {
if (f.el.style.opacity !== "1") f.el.style.opacity = "1";
const c = 2 * FLY_ZETA * Math.sqrt(FLY_K);
const boost = f.t < BUOY_T ? BUOYANCY : 0;
f.vx += (-FLY_K * f.ox - c * f.vx) * dt;
f.vy += (-FLY_K * f.oy - c * f.vy - boost) * dt;
f.ox += f.vx * dt;
f.oy += f.vy * dt;
const arrived =
Math.abs(f.ox) < 1 &&
Math.abs(f.oy) < 1 &&
Math.abs(f.vx) + Math.abs(f.vy) < 24;
if (arrived || f.t >= SETTLE_DEADLINE) {
// forced-settle deadline — a chip never hovers past 1.4s
f.phase = "squash";
f.squashT = 0;
f.s = SQUASH_S;
f.sv = 0;
f.el.style.transformOrigin = "50% 100%";
f.el.style.transform = `scaleY(${SQUASH_S})`;
} else {
const sway =
SWAY_AMP *
Math.exp(-SWAY_DECAY * f.t) *
Math.sin(Math.PI * 2 * SWAY_HZ * f.t);
f.el.style.transform = `translate3d(${(f.ox + sway).toFixed(2)}px, ${f.oy.toFixed(2)}px, 0)`;
}
} else {
f.squashT += dt;
if (f.squashT >= SQUASH_HOLD) {
const c = 2 * SQUASH_ZETA * Math.sqrt(SQUASH_K);
f.sv += (-SQUASH_K * (f.s - 1) - c * f.sv) * dt;
f.s += f.sv * dt;
if (Math.abs(f.s - 1) < 0.004 && Math.abs(f.sv) < 0.08) {
settleChip(f);
continue;
}
}
f.el.style.transform = `scaleY(${f.s.toFixed(4)})`;
}
}
// -- rejected-chip sinks ----------------------------------------------
for (const [el, s] of sinks) {
if (!el.isConnected) {
sinks.delete(el);
continue;
}
s.age += dt;
s.vy += SINK_G * dt;
s.dy = Math.min(SINK_DIST, s.dy + s.vy * dt);
el.style.transform = `translate3d(0, ${s.dy.toFixed(2)}px, 0)`;
el.style.opacity = Math.max(0, 1 - s.age / SINK_FADE).toFixed(3);
if (s.age >= SINK_FADE) {
sinks.delete(el);
finishSink(s.id);
} else {
active = true;
}
}
if (active) raf = requestAnimationFrame(loop);
else last = 0; // sleep — all chips docked, all wisps dead
};
const wake = () => {
if (!raf && !reduced) raf = requestAnimationFrame(loop);
};
engineRef.current = {
setDrag: (on) => {
if (reduced) return; // static token-border highlight only
dragging = on;
if (on) {
dragLeft = zone.getBoundingClientRect().left;
spawnAcc = 0;
wake();
}
},
dragPoint: (clientX) => {
pointerX = clientX - dragLeft;
},
fly: (el, cx, cy, delayMs) => {
if (reduced) return; // chips appear docked instantly
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) return; // zero-size guard — dock instantly
const f: Flight = {
el,
ox: cx - (r.left + r.width / 2),
oy: cy - (r.top + r.height / 2),
vx: 0,
vy: 0,
t: -delayMs / 1000,
phase: "fly",
squashT: 0,
s: 1,
sv: 0,
};
el.style.willChange = "transform";
el.style.zIndex = "20";
el.style.opacity = delayMs > 0 ? "0" : "1";
el.style.transform = `translate3d(${f.ox.toFixed(2)}px, ${f.oy.toFixed(2)}px, 0)`;
flights.set(el, f);
wake();
},
sink: (el, id) => {
if (sinks.has(el)) return;
sinks.set(el, { id, vy: 0, dy: 0, age: 0 });
wake();
},
};
const ro = new ResizeObserver(resize);
ro.observe(zone);
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible) wake();
});
io.observe(root);
// live theme re-derive: watch documentElement class flips
const mo = new MutationObserver(derive);
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const onVis = () => {
if (!document.hidden) wake();
};
document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
mo.disconnect();
document.removeEventListener("visibilitychange", onVis);
wisps.length = 0;
flights.clear();
sinks.clear();
engineRef.current = null;
};
}, []);
// -- landing-card autoplay bridge -----------------------------------------
// The component's only live interaction is the native HTML5 drag/drop API
// (dragenter/dragover/dragleave/drop with a real DataTransfer) — there is
// no pointer/hover/press affordance to synthesize against, so the shared
// autoplay driver (which only dispatches Pointer/Mouse events) cannot
// reach it as-is. This bridges the driver's synthetic pointer sweep into
// the exact same setDrag/dragPoint calls the real dragenter/dragover
// handlers already make, and — on the synthetic "leave" that closes each
// sweep — into the exact same processFiles() the real onDrop handler
// calls, with a File built via the standard constructible DataTransfer.
// No new animation or state machine: every visual (wisps, border, the
// buoy-and-dock flight) is the engine that already exists for real drops.
// Native listeners (not React props) mirror the pattern this repo already
// uses for driver-driven hover (dock-cursor-magnify's
// addEventListener("pointerleave", ...)) so the driver's directly-
// dispatched, non-bubbling pointerleave is caught reliably.
// Gated on [data-autoplay-root]: the preview route only ever stamps that
// attribute for `embed=1&autoplay=1` cards, so a real mouse hover on
// /preview/file-upload-thermal (or plain ?embed=1) never wires this up and
// behaves exactly as before.
useEffect(() => {
const zone = zoneRef.current;
if (!zone || !zone.closest("[data-autoplay-root]")) return;
let dragging = false;
const baseline = () => defaultFiles.map((f, i) => ({ ...f, id: `uf-d${i}` }));
const onMove = (e: PointerEvent) => {
if (!dragging) {
dragging = true;
setIsOver(true);
engineRef.current?.setDrag(true);
}
engineRef.current?.dragPoint(e.clientX);
};
const onLeave = () => {
if (!dragging) return;
dragging = false;
setIsOver(false);
engineRef.current?.setDrag(false);
// bound growth: clear the chip the *previous* sweep dropped before
// adding this sweep's — so a chip stays docked and visible for a
// full cycle (not just the ~300ms rest beat) and a card left running
// never accumulates chips.
setFiles(baseline());
onFilesChange?.(baseline());
// demo "drop": a synthetic accepted file rides the same buoy-and-dock
// flight a real drop uses, via the same processFiles() a real onDrop
// calls — accept-types validation runs on it exactly as it would on
// a real file.
const dt = new DataTransfer();
dt.items.add(new File(["demo"], "render-log.txt", { type: "text/plain" }));
processFiles(dt.files, null);
};
zone.addEventListener("pointermove", onMove);
zone.addEventListener("pointerleave", onLeave);
return () => {
zone.removeEventListener("pointermove", onMove);
zone.removeEventListener("pointerleave", onLeave);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFiles]);
// launch flights for freshly docked chips — layout effect so the offset
// transform lands before paint (no flash of the chip already docked)
useLayoutEffect(() => {
const pend = pendingRef.current;
if (pend.length === 0) return;
pendingRef.current = [];
for (const p of pend) {
const el = chipEls.current.get(p.id);
if (el) engineRef.current?.fly(el, p.cx, p.cy, p.delay);
}
}, [files]);
const validate = (file: File): string | null => {
if (file.size > maxSizeBytes)
return `exceeds the ${formatSize(maxSizeBytes)} limit`;
if (accept.length > 0) {
const lower = file.name.toLowerCase();
const ok = accept.some((a) => {
const s = a.trim().toLowerCase();
if (s.startsWith(".")) return lower.endsWith(s);
if (s.endsWith("/*")) return file.type.startsWith(s.slice(0, -1));
return file.type === s;
});
if (!ok) return "file type not accepted";
}
return null;
};
const processFiles = (
list: FileList | null,
point: { x: number; y: number } | null
) => {
if (!list || list.length === 0) return;
const zr = zoneRef.current?.getBoundingClientRect();
const px = point?.x ?? (zr ? zr.left + zr.width / 2 : 0);
const py = point?.y ?? (zr ? zr.top + zr.height / 2 : 0);
const accepted: UpdraftFile[] = [];
const newRejects: RejectChip[] = [];
const messages: string[] = [];
for (const file of Array.from(list)) {
const reason = validate(file);
if (reason === null) {
accepted.push({
id: `uf-${idSeq.current++}`,
name: file.name,
size: file.size,
type: file.type,
});
} else {
messages.push(`${file.name} rejected — ${reason}`);
if (!reducedRef.current && zr) {
newRejects.push({
id: `rj-${idSeq.current++}`,
name: file.name,
x: Math.min(zr.width - 8, Math.max(8, px - zr.left)),
y: Math.min(zr.height - 8, Math.max(8, py - zr.top)),
});
}
}
}
if (accepted.length > 0) {
if (!reducedRef.current) {
pendingRef.current.push(
...accepted.map((f, i) => ({
id: f.id,
cx: px,
cy: py,
delay: i * STAGGER_MS, // stagger so flights never overlap
}))
);
}
const next = [...files, ...accepted];
setFiles(next);
onFilesChange?.(next);
messages.push(...accepted.map((f) => `${f.name} added`));
}
if (newRejects.length > 0) setRejects((rs) => [...rs, ...newRejects]);
if (messages.length > 0) setAnnounce(messages.join(". "));
};
const removeFile = (id: string) => {
const i = files.findIndex((f) => f.id === id);
if (i === -1) return;
const removed = files[i];
const neighbor = files[i + 1] ?? files[i - 1];
const next = files.filter((f) => f.id !== id);
setFiles(next);
onFilesChange?.(next);
if (removed) setAnnounce(`${removed.name} removed`);
queueMicrotask(() => {
(neighbor
? chipEls.current.get(neighbor.id)
: zoneRef.current
)?.focus();
});
};
const openPicker = () => inputRef.current?.click();
const onZoneKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openPicker();
}
};
const dragHasFiles = (e: DragEvent<HTMLDivElement>) =>
Array.from(e.dataTransfer?.types ?? []).includes("Files");
const onDragEnter = (e: DragEvent<HTMLDivElement>) => {
if (!dragHasFiles(e)) return;
e.preventDefault();
dragDepth.current += 1;
if (dragDepth.current === 1) {
setIsOver(true);
engineRef.current?.setDrag(true);
}
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!dragHasFiles(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
engineRef.current?.dragPoint(e.clientX);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
if (!dragHasFiles(e)) return;
e.preventDefault();
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) {
setIsOver(false);
engineRef.current?.setDrag(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
if (!dragHasFiles(e)) return;
e.preventDefault();
dragDepth.current = 0;
setIsOver(false);
engineRef.current?.setDrag(false);
processFiles(e.dataTransfer?.files ?? null, {
x: e.clientX,
y: e.clientY,
});
};
const hint = `${acceptSummary(accept)} · max ${formatSize(maxSizeBytes)}`;
return (
<div ref={rootRef} className={`flex flex-col gap-3 ${className}`}>
<div
ref={zoneRef}
role="button"
tabIndex={0}
aria-label={ariaLabel}
aria-describedby={hintId}
onClick={openPicker}
onKeyDown={onZoneKey}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`relative flex min-h-[8.5rem] cursor-pointer flex-col items-center justify-center gap-1.5 overflow-hidden rounded-md border border-dashed px-6 py-8 text-center outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
isOver
? "border-accent bg-accent/[0.05]"
: "border-border bg-surface hover:border-foreground/25"
}`}
>
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none absolute left-0 top-0"
/>
<svg
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`relative z-[1] h-5 w-5 transition-colors duration-200 ${
isOver ? "text-accent" : "text-muted"
}`}
>
<path d="M12 16V4" />
<path d="m7 9 5-5 5 5" />
<path d="M4 20h16" />
</svg>
<p className="relative z-[1] text-sm text-foreground">
Drop files to attach, or{" "}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openPicker();
}}
className="rounded-[4px] font-medium text-accent underline-offset-2 outline-none transition-colors hover:underline focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
browse
</button>
</p>
<p
id={hintId}
className="relative z-[1] font-mono text-[11px] tracking-wide text-muted"
>
{hint}
</p>
{/* rejected files sink and fade inside the zone */}
{rejects.map((r) => (
<div
key={r.id}
aria-hidden
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-1/2"
style={{ left: r.x, top: r.y }}
>
<div
ref={(el) => {
if (el) engineRef.current?.sink(el, r.id);
}}
className="flex max-w-[11rem] items-center gap-1.5 rounded-sm border border-border bg-background px-2 py-1 shadow-sm"
>
<span className="truncate text-[11px] text-muted line-through">
{r.name}
</span>
</div>
</div>
))}
</div>
<input
ref={inputRef}
type="file"
multiple
accept={accept.length > 0 ? accept.join(",") : undefined}
tabIndex={-1}
aria-hidden="true"
className="sr-only"
onChange={(e) => {
processFiles(e.target.files, null);
e.target.value = "";
}}
/>
{files.length > 0 && (
<ul role="list" aria-label="Attached files" className="flex flex-wrap gap-2">
{files.map((f) => (
<li
key={f.id}
ref={(el) => {
if (el) chipEls.current.set(f.id, el);
else chipEls.current.delete(f.id);
}}
tabIndex={0}
aria-label={`${f.name}, ${formatSize(f.size)}. Press Delete to remove.`}
onKeyDown={(e) => {
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
removeFile(f.id);
}
}}
className="relative flex items-center gap-2 rounded-sm border border-border bg-surface py-1.5 pl-2.5 pr-1.5 outline-none transition-colors hover:border-foreground/25 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<svg
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3.5 w-3.5 shrink-0 text-muted"
>
{typeIconPath(f.name, f.type)}
</svg>
<span className="max-w-[10rem] truncate text-xs text-foreground">
{f.name}
</span>
<span className="font-mono text-[10px] text-muted">
{formatSize(f.size)}
</span>
<button
type="button"
aria-label={`Remove ${f.name}`}
onClick={() => removeFile(f.id)}
className="rounded-[4px] p-0.5 text-muted outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent"
>
<svg
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className="h-3 w-3"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
)}
<p
id={liveId}
role="status"
aria-live="polite"
className="min-h-4 font-mono text-[11px] text-muted"
>
{announce}
</p>
</div>
);
}
Build a file dropzone as a thermal field: a canvas 2D wisp layer over the zone (canvas-over-control pattern, explicit style.width/height because a canvas is a replaced element) plus DOM file chips animated with direct-DOM transforms, and a hidden <input type=file multiple> behind a real browse button. DRAGOVER: spawn convection wisps at 18/s (cap 30 live) from the zone floor biased toward the dragged pointer's column, rising 30-60 px/s with sinusoidal x-wobble amplitude 6px period 1.2s, life 1.5-2.5s, drawn as short quadratic strokes at peak alpha 0.12 from --accent with a sin-envelope fade; pruned particle array with a full clear + redraw per frame, never destination-in fades. ACCEPTED CHIP: chips are DOM list items anchored to their dock slots and flown with OFFSET transforms measured from drop point to slot center: a 900 px/s^2 buoyancy boost for the first 180ms feeding a near-critically damped spring pull to the slot, horizontal sway +-10px at 2 Hz with a zeta~0.5 decay envelope, landing squash scaleY 0.88 held 120ms then sprung back with k=300 s^-2 zeta=0.6; multi-drop flights stagger 90ms (chips hidden at the drop offset until their turn) so flights never overlap, and every chip carries a forced-settle deadline of 1.4s. REJECTED CHIP: spawns at the drop point inside the zone, sinks 40px max under 600 px/s^2 gravity while fading over 500ms, then is removed; the rejection reason (type not accepted / exceeds size limit) and each accepted file's name are announced via a polite aria-live status line. INTERACTION: zone is focusable role=button with Enter/Space opening the picker, aria-describedby points at the accepted-types + max-size rules line, docked chips are focusable list items removable via Delete/Backspace or an explicit remove button with focus handed to a neighbor. Validation supports extensions (.png), exact mime, and mime wildcards (image/*) plus a per-file byte limit. REDUCED MOTION: no wisps, chips appear docked instantly, dragover falls back to the static token-border accent highlight. ENGINE: all canvas ink parsed from getComputedStyle CSS tokens at mount and re-derived live via a MutationObserver on documentElement class so both themes render correctly; single direct-DOM rAF loop that sleeps when all chips are docked and all wisps dead, pauses offscreen via IntersectionObserver and on document hide, guards zero-size zones and zero-size chips before animating, and tears down every listener, observer, and frame on unmount. Hover/focus/dragover affordances are token-relative (border-foreground/25, ring-accent, border-accent), never hardcoded white.