Particle Field + Cursor
An interactive Canvas 2D particle field where hundreds of drifting dots weave a living constellation, linking with proximity lines and bending toward or away from your cursor in real time.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background:
radial-gradient(1200px 700px at 80% -10%, rgba(139, 92, 246, 0.18), transparent 60%),
radial-gradient(900px 600px at 0% 110%, rgba(34, 211, 238, 0.12), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.stage {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.field {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
cursor: crosshair;
touch-action: none;
}
/* Titlebar */
.titlebar {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 22px 26px;
display: flex;
flex-direction: column;
gap: 4px;
pointer-events: none;
z-index: 2;
}
.titlebar__brand {
display: flex;
align-items: center;
gap: 10px;
}
.titlebar__brand .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.18), 0 0 14px var(--accent);
}
.titlebar h1 {
margin: 0;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.2px;
}
.titlebar__hint {
margin: 0;
font-size: 12.5px;
color: var(--muted);
}
/* Panel */
.panel {
position: absolute;
right: 20px;
bottom: 20px;
width: 268px;
padding: 16px 16px 14px;
z-index: 3;
background: color-mix(in srgb, var(--surface) 82%, transparent);
border: 1px solid var(--line);
border-radius: var(--r-lg);
backdrop-filter: blur(14px) saturate(120%);
-webkit-backdrop-filter: blur(14px) saturate(120%);
box-shadow: 0 18px 50px -20px rgba(0, 0, 0, 0.7);
}
.panel__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.panel__head h2 {
margin: 0;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
.stat {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 3px 9px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--line);
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.stat__val {
font-size: 13px;
font-weight: 500;
color: var(--accent-2);
min-width: 20px;
text-align: right;
}
.stat__unit {
font-size: 10px;
color: var(--muted);
}
.control {
margin-bottom: 14px;
}
.control label,
.control__label {
display: block;
font-size: 12px;
color: var(--ink);
margin-bottom: 8px;
font-weight: 500;
}
.control output {
float: right;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
color: var(--muted);
}
.control output .u {
opacity: 0.6;
margin-left: 1px;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
background-repeat: no-repeat;
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--surface);
box-shadow: 0 0 0 1px var(--line-2), 0 2px 6px rgba(0, 0, 0, 0.5);
transition: transform 0.12s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.12);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--surface);
box-shadow: 0 0 0 1px var(--line-2);
}
input[type="range"]:focus-visible {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
}
/* Segmented control */
.control--seg {
margin-bottom: 16px;
}
.seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.seg__btn {
appearance: none;
border: 0;
padding: 7px 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.seg__btn:hover {
color: var(--ink);
}
.seg__btn.is-active {
background: linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 80%, black));
color: #fff;
box-shadow: 0 2px 10px -2px rgba(139, 92, 246, 0.6);
}
.seg__btn:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* Footer */
.panel__foot {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.btn {
appearance: none;
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
font: inherit;
font-size: 12.5px;
font-weight: 500;
padding: 7px 16px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.06s ease;
}
.btn:hover {
background: #262833;
border-color: var(--accent);
}
.btn:active {
transform: translateY(1px);
}
.btn[aria-pressed="true"] {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 50%, var(--line-2));
}
.btn:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.panel__meta {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 11.5px;
color: var(--muted);
}
.panel__meta #linkCount {
color: var(--accent-2);
}
@media (max-width: 520px) {
.titlebar {
padding: 16px 18px;
}
.titlebar__hint {
display: none;
}
.panel {
left: 14px;
right: 14px;
bottom: 14px;
width: auto;
}
}(function () {
"use strict";
var canvas = document.getElementById("field");
var ctx = canvas && canvas.getContext ? canvas.getContext("2d") : null;
// Graceful fallback if Canvas 2D is unavailable.
if (!ctx) {
if (canvas) {
canvas.setAttribute("aria-hidden", "false");
canvas.outerHTML =
'<p style="position:absolute;inset:0;display:grid;place-items:center;color:#9a9cab">' +
"Canvas 2D is not supported in this browser.</p>";
}
return;
}
// --- Controls -------------------------------------------------------------
var countEl = document.getElementById("count");
var countOut = document.getElementById("countOut");
var distEl = document.getElementById("dist");
var distOut = document.getElementById("distOut");
var toggleBtn = document.getElementById("toggle");
var fpsEl = document.getElementById("fps");
var linkCountEl = document.getElementById("linkCount");
var segBtns = Array.prototype.slice.call(document.querySelectorAll(".seg__btn"));
var reduceMotion =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// --- State ----------------------------------------------------------------
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var W = 0; // CSS pixels
var H = 0;
var particles = [];
var targetCount = parseInt(countEl.value, 10);
var linkDist = parseInt(distEl.value, 10);
var mode = "attract"; // 'attract' | 'repel'
var running = !reduceMotion;
var pointer = { x: -9999, y: -9999, active: false };
var CURSOR_RADIUS = 150; // influence radius in CSS px
var CURSOR_FORCE = 0.55;
// Accent colors sampled from the design tokens.
var PARTICLE_RGB = [186, 176, 255]; // soft violet-white
var LINE_RGB = [139, 92, 246]; // --accent
function rand(a, b) {
return a + Math.random() * (b - a);
}
function makeParticle() {
return {
x: Math.random() * W,
y: Math.random() * H,
vx: rand(-0.35, 0.35),
vy: rand(-0.35, 0.35),
r: rand(0.8, 2.2),
};
}
function syncCount() {
while (particles.length < targetCount) particles.push(makeParticle());
if (particles.length > targetCount) particles.length = targetCount;
}
// --- Sizing (DPR-aware) ---------------------------------------------------
function resize() {
var rect = canvas.getBoundingClientRect();
W = Math.max(1, rect.width);
H = Math.max(1, rect.height);
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
if (window.ResizeObserver) {
new ResizeObserver(resize).observe(canvas);
} else {
window.addEventListener("resize", resize);
}
// --- Physics --------------------------------------------------------------
function step() {
var i, p;
for (i = 0; i < particles.length; i++) {
p = particles[i];
// Cursor force
if (pointer.active) {
var dx = p.x - pointer.x;
var dy = p.y - pointer.y;
var d2 = dx * dx + dy * dy;
var R = CURSOR_RADIUS;
if (d2 < R * R && d2 > 0.01) {
var d = Math.sqrt(d2);
var falloff = (1 - d / R) * CURSOR_FORCE;
var nx = dx / d;
var ny = dy / d;
var sign = mode === "repel" ? 1 : -1;
p.vx += nx * falloff * sign;
p.vy += ny * falloff * sign;
}
}
// Integrate + gentle damping so the field settles back to a drift.
p.vx *= 0.96;
p.vy *= 0.96;
// Keep a minimum ambient drift alive.
var speed = Math.hypot(p.vx, p.vy);
if (speed < 0.12) {
p.vx += rand(-0.06, 0.06);
p.vy += rand(-0.06, 0.06);
}
p.x += p.vx;
p.y += p.vy;
// Wrap around edges.
if (p.x < -5) p.x = W + 5;
else if (p.x > W + 5) p.x = -5;
if (p.y < -5) p.y = H + 5;
else if (p.y > H + 5) p.y = -5;
}
}
// --- Rendering with a spatial grid for the connection pass -----------------
var links = 0;
function draw() {
ctx.clearRect(0, 0, W, H);
links = 0;
var cell = linkDist;
var cols = Math.max(1, Math.ceil(W / cell));
var rows = Math.max(1, Math.ceil(H / cell));
var grid = new Array(cols * rows);
var i, p;
for (i = 0; i < particles.length; i++) {
p = particles[i];
var cx = Math.min(cols - 1, Math.max(0, (p.x / cell) | 0));
var cy = Math.min(rows - 1, Math.max(0, (p.y / cell) | 0));
var idx = cy * cols + cx;
p._cx = cx;
p._cy = cy;
(grid[idx] || (grid[idx] = [])).push(p);
}
// Connection lines — only compare against neighbouring cells.
var maxD2 = linkDist * linkDist;
for (i = 0; i < particles.length; i++) {
p = particles[i];
for (var oy = -1; oy <= 1; oy++) {
for (var ox = -1; ox <= 1; ox++) {
var gx = p._cx + ox;
var gy = p._cy + oy;
if (gx < 0 || gy < 0 || gx >= cols || gy >= rows) continue;
var bucket = grid[gy * cols + gx];
if (!bucket) continue;
for (var j = 0; j < bucket.length; j++) {
var q = bucket[j];
if (q === p) continue;
// Avoid drawing each pair twice.
if (q.x < p.x || (q.x === p.x && q.y <= p.y)) continue;
var ddx = p.x - q.x;
var ddy = p.y - q.y;
var dd2 = ddx * ddx + ddy * ddy;
if (dd2 > maxD2) continue;
var alpha = (1 - Math.sqrt(dd2) / linkDist) * 0.55;
ctx.strokeStyle =
"rgba(" + LINE_RGB[0] + "," + LINE_RGB[1] + "," + LINE_RGB[2] + "," + alpha + ")";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.stroke();
links++;
}
}
}
}
// Particles on top.
var pr = PARTICLE_RGB;
for (i = 0; i < particles.length; i++) {
p = particles[i];
var glow = 0;
if (pointer.active) {
var mdx = p.x - pointer.x;
var mdy = p.y - pointer.y;
var md2 = mdx * mdx + mdy * mdy;
if (md2 < CURSOR_RADIUS * CURSOR_RADIUS) {
glow = 1 - Math.sqrt(md2) / CURSOR_RADIUS;
}
}
var rr = p.r + glow * 1.6;
ctx.beginPath();
ctx.arc(p.x, p.y, rr, 0, Math.PI * 2);
ctx.fillStyle =
"rgba(" + pr[0] + "," + pr[1] + "," + pr[2] + "," + (0.55 + glow * 0.45) + ")";
ctx.fill();
}
// Cursor influence ring.
if (pointer.active) {
ctx.beginPath();
ctx.arc(pointer.x, pointer.y, CURSOR_RADIUS, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(34,211,238,0.14)";
ctx.lineWidth = 1;
ctx.stroke();
}
}
// --- Loop with FPS metering ----------------------------------------------
var lastFrame = performance.now();
var fpsAcc = 0;
var fpsFrames = 0;
var lastFpsUpdate = lastFrame;
var rafId = null;
function frame(now) {
var dt = now - lastFrame;
lastFrame = now;
if (running) step();
draw();
// FPS (smoothed, updated ~4x/sec).
fpsAcc += dt;
fpsFrames++;
if (now - lastFpsUpdate > 250) {
var fps = Math.round(1000 / (fpsAcc / fpsFrames));
fpsEl.textContent = String(Math.max(0, Math.min(fps, 120)));
linkCountEl.textContent = String(links);
fpsAcc = 0;
fpsFrames = 0;
lastFpsUpdate = now;
}
rafId = requestAnimationFrame(frame);
}
// --- Pointer --------------------------------------------------------------
function setPointer(e) {
var rect = canvas.getBoundingClientRect();
var pt = e.touches ? e.touches[0] : e;
pointer.x = pt.clientX - rect.left;
pointer.y = pt.clientY - rect.top;
pointer.active = true;
}
canvas.addEventListener("mousemove", setPointer);
canvas.addEventListener(
"touchmove",
function (e) {
setPointer(e);
e.preventDefault();
},
{ passive: false }
);
canvas.addEventListener("mouseleave", function () {
pointer.active = false;
pointer.x = pointer.y = -9999;
});
canvas.addEventListener("touchend", function () {
pointer.active = false;
});
// --- Control wiring --------------------------------------------------------
countEl.addEventListener("input", function () {
targetCount = parseInt(countEl.value, 10);
countOut.textContent = String(targetCount);
syncCount();
});
distEl.addEventListener("input", function () {
linkDist = parseInt(distEl.value, 10);
distOut.innerHTML = linkDist + '<span class="u">px</span>';
});
segBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
segBtns.forEach(function (b) {
b.classList.remove("is-active");
b.setAttribute("aria-checked", "false");
});
btn.classList.add("is-active");
btn.setAttribute("aria-checked", "true");
mode = btn.getAttribute("data-mode");
});
});
toggleBtn.addEventListener("click", function () {
running = !running;
toggleBtn.textContent = running ? "Pause" : "Resume";
toggleBtn.setAttribute("aria-pressed", String(!running));
});
// --- Boot -----------------------------------------------------------------
resize();
syncCount();
if (reduceMotion) {
// Respect reduced-motion: render a single static frame, let user resume.
running = false;
toggleBtn.textContent = "Resume";
toggleBtn.setAttribute("aria-pressed", "true");
draw();
}
rafId = requestAnimationFrame(frame);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Particle Field + Cursor</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage" aria-label="Interactive particle field">
<canvas id="field" class="field" aria-hidden="true"></canvas>
<header class="titlebar">
<div class="titlebar__brand">
<span class="dot" aria-hidden="true"></span>
<h1>Particle Field</h1>
</div>
<p class="titlebar__hint">Move your cursor across the field — the swarm reacts.</p>
</header>
<section class="panel" aria-label="Field controls">
<div class="panel__head">
<h2>Controls</h2>
<div class="stat" role="status" aria-live="off">
<span class="stat__val" id="fps">60</span>
<span class="stat__unit">fps</span>
</div>
</div>
<div class="control">
<label for="count">Particles</label>
<input id="count" type="range" min="40" max="600" step="10" value="220" />
<output id="countOut" for="count">220</output>
</div>
<div class="control">
<label for="dist">Connection distance</label>
<input id="dist" type="range" min="40" max="200" step="5" value="120" />
<output id="distOut" for="dist">120<span class="u">px</span></output>
</div>
<div class="control control--seg">
<span class="control__label" id="modeLabel">Cursor force</span>
<div class="seg" role="radiogroup" aria-labelledby="modeLabel">
<button type="button" class="seg__btn is-active" data-mode="attract" role="radio" aria-checked="true">Attract</button>
<button type="button" class="seg__btn" data-mode="repel" role="radio" aria-checked="false">Repel</button>
</div>
</div>
<div class="panel__foot">
<button type="button" id="toggle" class="btn" aria-pressed="false">Pause</button>
<span class="panel__meta"><span id="linkCount">0</span> links</span>
</div>
</section>
</main>
<script src="script.js"></script>
</body>
</html>An animated particle field rendered entirely on a single Canvas 2D context. Hundreds of lightweight particles drift with gentle velocity, wrapping around the edges of the viewport, while nearby particles are joined by fading lines to form a shifting constellation. The cursor acts as a force node — flip between attract and repel to pull the swarm into your pointer or scatter it away, with the effect strength falling off smoothly over distance.
Everything runs in a single requestAnimationFrame loop that is DPR-aware, so the field stays crisp on high-density displays without ballooning the pixel count. The connection pass uses a spatial grid so line-drawing cost scales with local neighbours instead of the full N² pair check, keeping the animation smooth even at high particle counts. A live FPS readout and control sliders let you dial in particle count and connection distance to see the performance tradeoffs directly.
This resource demonstrates the Canvas 2D rendering API together with requestAnimationFrame, devicePixelRatio scaling, and ResizeObserver. It also feature-detects matchMedia('(prefers-reduced-motion)') to pause continuous motion for users who ask for it, and it degrades gracefully to a static frame if canvas support is missing.