← back to Particle Text
Particle Text: complete single-file build with wrapping, themes, burst, live typing
8792c5c1b2d598f00c6ae8ecc9d2f6bf63872497 · 2026-07-24 23:37:33 -0700 · Steve
Files touched
A .gitignoreA README.mdA index.html
Diff
commit 8792c5c1b2d598f00c6ae8ecc9d2f6bf63872497
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 24 23:37:33 2026 -0700
Particle Text: complete single-file build with wrapping, themes, burst, live typing
---
.gitignore | 10 ++
README.md | 25 ++++
index.html | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 430 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..89b5735
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+render*.png
+_*.html
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ec4890d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# Free-Roster — Particle Text
+
+A single-file, dependency-free canvas toy. Type a word (or phrase) and thousands
+of particles fly in to spell it; sweep your mouse through the letters to scatter
+them, then watch them reform. Click anywhere for a radial burst.
+
+Open `index.html` in any modern browser — nothing to install.
+
+## Features
+- **Particle text** sampled from rendered glyphs, ~few-thousand particles.
+- **Live typing** — the word reforms as you type (debounced).
+- **Multi-word wrapping** — long phrases auto-fit and wrap onto centered lines.
+- **Mouse / touch scatter** with spring-back reformation.
+- **Click / tap burst** — radial impulse from the point.
+- **5 color themes** (Aurora / Sunset / Neon / Emerald / Ice) — cycle with **Theme**.
+- **Shuffle** — random preset word.
+- Additive-glow rendering with motion trails; subtle idle twinkle.
+- Respects `prefers-reduced-motion` (snappier settle, no twinkle).
+- Responsive; DPR-aware; rebuilds on resize.
+
+## Built from
+An improved, completed build of a Model Arena "Particle Text" challenge artifact.
+Fixes over the starting point: multi-word/long-text wrapping (was: illegible
+shrink), color themes, live-typing feedback, click burst, additive glow, jittered
+sampling, reduced-motion support, and debounced resize.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..0c4f92a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,395 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>Particle Text</title>
+<style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { height: 100%; overflow: hidden; background: #05060a; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ color: #e8ecf4;
+ -webkit-user-select: none; user-select: none;
+ touch-action: none;
+ }
+ #stage { position: fixed; inset: 0; }
+ canvas { display: block; width: 100%; height: 100%; }
+
+ .ui {
+ position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%);
+ display: flex; gap: 8px; align-items: center;
+ padding: 9px 10px;
+ background: rgba(18, 22, 34, 0.55);
+ border: 1px solid rgba(255,255,255,0.10);
+ border-radius: 16px;
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+ box-shadow: 0 12px 40px rgba(0,0,0,0.45);
+ z-index: 10;
+ max-width: calc(100vw - 20px);
+ flex-wrap: wrap; justify-content: center;
+ }
+ .ui input[type="text"] {
+ width: min(46vw, 300px);
+ padding: 11px 14px;
+ font-size: 15px;
+ color: #fff;
+ background: rgba(255,255,255,0.06);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 11px;
+ outline: none;
+ transition: border-color .2s, background .2s;
+ }
+ .ui input[type="text"]:focus {
+ border-color: rgba(120,180,255,0.6);
+ background: rgba(255,255,255,0.10);
+ }
+ .ui input::placeholder { color: rgba(255,255,255,0.35); }
+
+ .btn {
+ padding: 11px 16px;
+ font-size: 14px; font-weight: 600;
+ color: #071018;
+ background: linear-gradient(135deg, #8fd3ff, #b7a6ff);
+ border: none; border-radius: 11px;
+ cursor: pointer;
+ transition: transform .12s, filter .2s;
+ white-space: nowrap;
+ }
+ .btn:hover { filter: brightness(1.08); }
+ .btn:active { transform: scale(0.95); }
+ .btn.ghost {
+ color: #cdd6e6;
+ background: rgba(255,255,255,0.07);
+ border: 1px solid rgba(255,255,255,0.12);
+ font-weight: 500;
+ }
+ .btn.ghost:hover { background: rgba(255,255,255,0.13); }
+
+ .hint {
+ position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
+ font-size: 12.5px; letter-spacing: .3px;
+ color: rgba(255,255,255,0.42);
+ z-index: 10; text-align: center; pointer-events: none;
+ width: 100%; padding: 0 12px;
+ transition: opacity .6s;
+ }
+ .badge {
+ position: fixed; top: 18px; right: 16px;
+ font-size: 11px; font-variant-numeric: tabular-nums;
+ color: rgba(255,255,255,0.30);
+ z-index: 10; pointer-events: none;
+ letter-spacing: .4px;
+ }
+</style>
+</head>
+<body>
+ <div id="stage"><canvas id="c"></canvas></div>
+ <div class="hint" id="hint">Type a word · move through the letters to scatter · click for a burst</div>
+ <div class="badge" id="badge"></div>
+ <div class="ui">
+ <input id="word" type="text" value="HELLO" maxlength="24" spellcheck="false" autocomplete="off" placeholder="type a word…">
+ <button class="btn" id="go">Form</button>
+ <button class="btn ghost" id="theme" title="Cycle color theme">Theme</button>
+ <button class="btn ghost" id="rand" title="Random word">Shuffle</button>
+ </div>
+
+<script>
+(function () {
+ "use strict";
+
+ var canvas = document.getElementById("c");
+ var ctx = canvas.getContext("2d", { alpha: false });
+ var input = document.getElementById("word");
+ var goBtn = document.getElementById("go");
+ var themeBtn = document.getElementById("theme");
+ var randBtn = document.getElementById("rand");
+ var hintEl = document.getElementById("hint");
+ var badgeEl = document.getElementById("badge");
+
+ var reduceMotion = window.matchMedia &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+ var DPR = Math.min(window.devicePixelRatio || 1, 2);
+ var W = 0, H = 0;
+ var particles = [];
+ var mouse = { x: -9999, y: -9999, active: false, r: 90 };
+
+ // Offscreen canvas for sampling text pixels
+ var sampler = document.createElement("canvas");
+ var sctx = sampler.getContext("2d", { willReadFrequently: true });
+
+ // ---- color themes: t is normalized 0..1 across the text width ----
+ var themes = [
+ { name: "Aurora", fn: function (t) { return "hsl(" + (188 + t * 96) + ",90%,66%)"; } },
+ { name: "Sunset", fn: function (t) { return "hsl(" + (8 + t * 52) + ",95%,62%)"; } },
+ { name: "Neon", fn: function (t) { return "hsl(" + (t * 320) + ",92%,63%)"; } },
+ { name: "Emerald", fn: function (t) { return "hsl(" + (135 + t * 60) + ",80%,58%)"; } },
+ { name: "Ice", fn: function (t) { return "hsl(205,70%," + (62 + t * 26) + "%)"; } }
+ ];
+ var themeIdx = 0;
+
+ var WORDS = ["HELLO", "PARTICLES", "CANVAS", "MAGIC", "REFORM", "SCATTER",
+ "GALAXY", "AURORA", "TYPE ME", "PHYSICS", "SWARM", "GLOW"];
+
+ var FONT = "800 %spx -apple-system, 'Segoe UI', Roboto, Arial, sans-serif";
+
+ function resize() {
+ W = window.innerWidth;
+ H = window.innerHeight;
+ canvas.width = Math.floor(W * DPR);
+ canvas.height = Math.floor(H * DPR);
+ canvas.style.width = W + "px";
+ canvas.style.height = H + "px";
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+ mouse.r = Math.max(70, Math.min(W, H) * 0.11);
+ }
+
+ // Greedy word-wrap at a given font size (font must already be set on sctx)
+ function wrapLines(words, maxW) {
+ var lines = [];
+ var cur = "";
+ for (var i = 0; i < words.length; i++) {
+ var test = cur ? cur + " " + words[i] : words[i];
+ if (cur && sctx.measureText(test).width > maxW) {
+ lines.push(cur);
+ cur = words[i];
+ } else {
+ cur = test;
+ }
+ }
+ if (cur) lines.push(cur);
+ return lines;
+ }
+
+ // Fit text: choose the largest size where wrapped lines fit the box.
+ function fitText(text, maxW, maxH) {
+ var words = text.split(/\s+/).filter(Boolean);
+ if (!words.length) words = [text];
+ var size = Math.min(H * 0.30, 340);
+ var minSize = 24;
+ var best = null;
+ while (size >= minSize) {
+ sctx.font = FONT.replace("%s", size);
+ var lines = wrapLines(words, maxW);
+ var widest = 0;
+ for (var i = 0; i < lines.length; i++) {
+ var w = sctx.measureText(lines[i]).width;
+ if (w > widest) widest = w;
+ }
+ var lineH = size * 1.08;
+ var totalH = lines.length * lineH;
+ if (widest <= maxW && totalH <= maxH) {
+ best = { size: size, lines: lines, lineH: lineH };
+ break;
+ }
+ size -= 6;
+ }
+ if (!best) {
+ size = minSize;
+ sctx.font = FONT.replace("%s", size);
+ best = { size: size, lines: wrapLines(words, maxW), lineH: size * 1.08 };
+ }
+ return best;
+ }
+
+ // Sample target points from rendered (possibly multi-line) text
+ function targetsForText(text) {
+ text = (text || "").trim();
+ if (!text) return [];
+
+ sampler.width = W;
+ sampler.height = H;
+ sctx.clearRect(0, 0, W, H);
+ sctx.fillStyle = "#fff";
+ sctx.textAlign = "center";
+ sctx.textBaseline = "middle";
+
+ var fit = fitText(text, W * 0.86, H * 0.60);
+ sctx.font = FONT.replace("%s", fit.size);
+
+ var n = fit.lines.length;
+ var blockH = n * fit.lineH;
+ var startY = H / 2 - blockH / 2 + fit.lineH / 2;
+ for (var i = 0; i < n; i++) {
+ sctx.fillText(fit.lines[i], W / 2, startY + i * fit.lineH);
+ }
+
+ var img = sctx.getImageData(0, 0, W, H).data;
+ // gap scales so particle count stays in a pleasing few-thousand range
+ var area = W * H;
+ var gap = area > 2200000 ? 5 : (area > 1100000 ? 4 : 3);
+ var pts = [];
+ for (var y = 0; y < H; y += gap) {
+ for (var x = 0; x < W; x += gap) {
+ if (img[(y * W + x) * 4 + 3] > 128) {
+ // slight jitter so the grid never looks mechanical
+ pts.push({ x: x + (Math.random() - 0.5) * gap, y: y + (Math.random() - 0.5) * gap });
+ }
+ }
+ }
+ return pts;
+ }
+
+ function color(t) { return themes[themeIdx].fn(Math.max(0, Math.min(1, t))); }
+
+ function build(text) {
+ var pts = targetsForText(text);
+
+ // shuffle for organic fly-in
+ for (var i = pts.length - 1; i > 0; i--) {
+ var j = (Math.random() * (i + 1)) | 0;
+ var tmp = pts[i]; pts[i] = pts[j]; pts[j] = tmp;
+ }
+
+ var need = pts.length;
+ while (particles.length < need) {
+ particles.push({
+ x: Math.random() * W, y: Math.random() * H,
+ vx: 0, vy: 0, tx: 0, ty: 0,
+ size: 1.6, color: "#8fd3ff", twk: Math.random() * 6.283
+ });
+ }
+ if (particles.length > need) particles.length = need;
+
+ for (var k = 0; k < need; k++) {
+ var p = particles[k];
+ p.tx = pts[k].x;
+ p.ty = pts[k].y;
+ p.color = color(pts[k].x / W);
+ p.size = 1.4 + Math.random() * 1.7;
+ // a gentle inward kick so formation reads as a "fly-in"
+ p.vx += (Math.random() - 0.5) * 2;
+ p.vy += (Math.random() - 0.5) * 2;
+ }
+
+ badgeEl.textContent = need.toLocaleString() + " particles · " + themes[themeIdx].name;
+ }
+
+ // click / tap = radial burst impulse from the point
+ function burst(x, y) {
+ var R = Math.min(W, H) * 0.45, R2 = R * R;
+ for (var i = 0; i < particles.length; i++) {
+ var p = particles[i];
+ var dx = p.x - x, dy = p.y - y;
+ var d2 = dx * dx + dy * dy;
+ if (d2 < R2) {
+ var d = Math.sqrt(d2) || 1;
+ var f = (1 - d / R) * 22;
+ p.vx += (dx / d) * f;
+ p.vy += (dy / d) * f;
+ }
+ }
+ }
+
+ function tick() {
+ // fade trail
+ ctx.globalCompositeOperation = "source-over";
+ ctx.fillStyle = "rgba(5, 6, 10, 0.30)";
+ ctx.fillRect(0, 0, W, H);
+
+ // additive glow for the particles
+ ctx.globalCompositeOperation = "lighter";
+
+ var mr = mouse.r, mr2 = mr * mr;
+ var spring = reduceMotion ? 0.10 : 0.020;
+ var friction = reduceMotion ? 0.75 : 0.88;
+
+ for (var i = 0; i < particles.length; i++) {
+ var p = particles[i];
+
+ var dx = p.tx - p.x, dy = p.ty - p.y;
+ p.vx += dx * spring;
+ p.vy += dy * spring;
+
+ if (mouse.active) {
+ var mx = p.x - mouse.x, my = p.y - mouse.y;
+ var d2 = mx * mx + my * my;
+ if (d2 < mr2 && d2 > 0.0001) {
+ var d = Math.sqrt(d2);
+ var force = (mr - d) / mr;
+ var f = force * 5.4;
+ p.vx += (mx / d) * f;
+ p.vy += (my / d) * f;
+ }
+ }
+
+ p.vx *= friction;
+ p.vy *= friction;
+ p.x += p.vx;
+ p.y += p.vy;
+
+ // subtle twinkle in size when at rest
+ var s = p.size;
+ if (!reduceMotion) {
+ p.twk += 0.05;
+ s += Math.sin(p.twk) * 0.25;
+ }
+
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, s, 0, 6.2832);
+ ctx.fill();
+ }
+
+ requestAnimationFrame(tick);
+ }
+
+ // ---- events ----
+ function moveHandler(x, y) { mouse.x = x; mouse.y = y; mouse.active = true; }
+ window.addEventListener("mousemove", function (e) { moveHandler(e.clientX, e.clientY); });
+ window.addEventListener("mouseout", function () { mouse.active = false; });
+ window.addEventListener("touchmove", function (e) {
+ if (e.touches[0]) moveHandler(e.touches[0].clientX, e.touches[0].clientY);
+ }, { passive: true });
+ window.addEventListener("touchend", function () { mouse.active = false; });
+
+ // burst on click (ignore clicks on the UI)
+ function isUI(t) { return t && t.closest && t.closest(".ui"); }
+ window.addEventListener("pointerdown", function (e) {
+ if (isUI(e.target)) return;
+ burst(e.clientX, e.clientY);
+ });
+
+ var resizeTimer;
+ window.addEventListener("resize", function () {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(function () { resize(); build(input.value); }, 150);
+ });
+
+ // live rebuild while typing (debounced)
+ var typeTimer;
+ input.addEventListener("input", function () {
+ clearTimeout(typeTimer);
+ typeTimer = setTimeout(function () { build(input.value); }, 260);
+ });
+ input.addEventListener("keydown", function (e) {
+ if (e.key === "Enter") { clearTimeout(typeTimer); build(input.value); input.blur(); }
+ });
+
+ goBtn.addEventListener("click", function () { build(input.value); input.blur(); });
+ themeBtn.addEventListener("click", function () {
+ themeIdx = (themeIdx + 1) % themes.length;
+ build(input.value);
+ });
+ randBtn.addEventListener("click", function () {
+ var w;
+ do { w = WORDS[(Math.random() * WORDS.length) | 0]; } while (w === input.value);
+ input.value = w;
+ build(w);
+ });
+
+ // fade the hint after a bit
+ setTimeout(function () { hintEl.style.opacity = "0.35"; }, 6000);
+
+ // ---- init ----
+ resize();
+ ctx.fillStyle = "#05060a";
+ ctx.fillRect(0, 0, W, H);
+ build(input.value);
+ requestAnimationFrame(tick);
+})();
+</script>
+</body>
+</html>
(oldest)
·
back to Particle Text
·
Name this build: Starling (.aa marker) abbf59c →