Dot Grid Ripple
A DPR-aware canvas grid of dots that breathe with a continuous idle wave, brighten and swell under the cursor, and burst into expanding ripples on click. Every dot interpolates its own scale, brightness and hue toward a target driven by distance fields, so the surface feels fluid and alive. Includes live controls for spacing, wave speed, cursor radius and color mode, plus reduced-motion support and a crisp resize-aware render loop.
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.10);
--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: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.stage {
position: relative;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
background:
radial-gradient(120% 90% at 15% 0%, rgba(139, 92, 246, 0.14), transparent 55%),
radial-gradient(120% 90% at 100% 100%, rgba(34, 211, 238, 0.10), transparent 55%),
var(--bg);
}
.grid-canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: crosshair;
}
/* ---- Hero overlay ---- */
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: clamp(20px, 5vw, 56px);
pointer-events: none;
}
.hero {
max-width: 30rem;
}
.eyebrow {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-2);
background: rgba(34, 211, 238, 0.10);
border: 1px solid rgba(34, 211, 238, 0.28);
padding: 5px 11px;
border-radius: 999px;
}
.hero h1 {
margin: 16px 0 8px;
font-size: clamp(2rem, 5vw, 3.1rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.05;
background: linear-gradient(120deg, var(--ink) 30%, #b79dff 70%, var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p {
margin: 0;
color: var(--muted);
font-size: clamp(0.95rem, 2vw, 1.06rem);
max-width: 24rem;
}
/* ---- Control panel ---- */
.panel {
position: absolute;
right: clamp(16px, 4vw, 40px);
bottom: clamp(16px, 4vw, 40px);
width: min(290px, calc(100vw - 32px));
background: color-mix(in srgb, var(--surface) 86%, transparent);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
display: grid;
gap: 16px;
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.7);
z-index: 3;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-head h2 {
margin: 0;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.dot-pulse {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.6);
animation: pulse 2.4s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.5); }
70% { box-shadow: 0 0 0 8px rgba(52, 211, 153, 0); }
100% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0); }
}
.control {
display: grid;
gap: 8px;
}
.control label,
.control-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.82rem;
font-weight: 500;
color: var(--ink);
}
.control output {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.76rem;
color: var(--accent-2);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
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(--accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
transition: transform 0.12s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
input[type="range"]:focus-visible {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
}
/* segmented control */
.seg {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 4px;
}
.seg-btn {
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 0.78rem;
font-weight: 500;
padding: 7px 4px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.seg-btn:hover {
color: var(--ink);
}
.seg-btn.is-active {
background: linear-gradient(135deg, var(--accent), #6d3ff0);
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;
}
/* toggle switch */
.control-toggle {
grid-template-columns: 1fr auto;
align-items: center;
}
.switch {
position: relative;
width: 44px;
height: 26px;
border-radius: 999px;
border: 1px solid var(--line-2);
background: var(--surface-2);
cursor: pointer;
padding: 0;
transition: background 0.2s ease;
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--muted);
transition: transform 0.2s ease, background 0.2s ease;
}
.switch.is-on {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent;
}
.switch.is-on .knob {
transform: translateX(18px);
background: #fff;
}
.switch:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.burst-btn {
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
font: inherit;
font-weight: 600;
font-size: 0.85rem;
padding: 10px;
border-radius: var(--r-sm);
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.08s ease;
}
.burst-btn:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, var(--surface-2));
}
.burst-btn:active {
transform: translateY(1px);
}
.burst-btn:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 0;
padding-top: 4px;
border-top: 1px solid var(--line);
}
.stats div {
background: var(--surface-2);
border-radius: var(--r-sm);
padding: 8px 10px;
}
.stats dt {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.stats dd {
margin: 2px 0 0;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 1.05rem;
font-weight: 500;
color: var(--ink);
}
.reduced-note {
position: absolute;
left: clamp(16px, 4vw, 40px);
bottom: clamp(16px, 4vw, 40px);
margin: 0;
max-width: 20rem;
font-size: 0.8rem;
color: var(--warn);
background: rgba(251, 191, 36, 0.10);
border: 1px solid rgba(251, 191, 36, 0.3);
padding: 8px 12px;
border-radius: var(--r-sm);
z-index: 3;
}
@media (max-width: 520px) {
.overlay {
padding: 18px;
}
.panel {
left: 12px;
right: 12px;
bottom: 12px;
width: auto;
}
.reduced-note {
display: none;
}
}/* Dot Grid Ripple — DPR-aware canvas wave that reacts to pointer + click ripples. */
(function () {
"use strict";
var canvas = document.getElementById("grid");
var ctx = canvas.getContext("2d");
var reduceQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
var reduced = reduceQuery.matches;
// ---- state / config -----------------------------------------------------
var config = {
spacing: 30, // css px between dots
speed: 1, // idle wave speed multiplier
radius: 150, // cursor influence radius (css px)
color: "mono", // mono | dual | spectrum
idle: true
};
var dpr = 1;
var W = 0; // css pixel width
var H = 0; // css pixel height
var dots = []; // {x, y, s} where s is eased scale 0..1
var ripples = []; // {x, y, t0, life}
var pointer = { x: -9999, y: -9999, active: false };
var lastFrame = performance.now();
var fpsAccum = 0, fpsFrames = 0, fps = 0;
var statDots = document.getElementById("stat-dots");
var statFps = document.getElementById("stat-fps");
var reducedNote = document.getElementById("reduced-note");
// ---- grid build ----------------------------------------------------------
function buildGrid() {
dots.length = 0;
var gap = config.spacing;
// center the lattice with a symmetric margin
var cols = Math.max(1, Math.floor((W - gap) / gap));
var rows = Math.max(1, Math.floor((H - gap) / gap));
var offX = (W - cols * gap) / 2 + gap / 2;
var offY = (H - rows * gap) / 2 + gap / 2;
for (var r = 0; r <= rows; r++) {
for (var c = 0; c <= cols; c++) {
dots.push({ x: offX + c * gap, y: offY + r * gap, s: 0 });
}
}
if (statDots) statDots.textContent = dots.length.toLocaleString();
}
// ---- resize (DPR aware) --------------------------------------------------
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
var rect = canvas.getBoundingClientRect();
W = Math.max(1, rect.width);
H = Math.max(1, rect.height);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
buildGrid();
}
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(resize).observe(canvas);
} else {
window.addEventListener("resize", resize);
}
// ---- interaction ---------------------------------------------------------
function relPos(evt) {
var rect = canvas.getBoundingClientRect();
return { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
}
canvas.addEventListener("pointermove", function (e) {
var p = relPos(e);
pointer.x = p.x;
pointer.y = p.y;
pointer.active = true;
});
canvas.addEventListener("pointerleave", function () {
pointer.active = false;
pointer.x = -9999;
pointer.y = -9999;
});
function addRipple(x, y) {
var maxDim = Math.hypot(W, H);
ripples.push({ x: x, y: y, t0: performance.now(), life: 1100, reach: maxDim });
if (ripples.length > 24) ripples.shift();
}
canvas.addEventListener("pointerdown", function (e) {
var p = relPos(e);
addRipple(p.x, p.y);
});
// occasional automatic ripples so the surface never feels dead (unless reduced)
var autoTimer = 0;
// ---- color helpers -------------------------------------------------------
function dotColor(intensity, phase) {
// intensity 0..1 -> brightness; phase used by spectrum/dual
var a = 0.12 + intensity * 0.88;
if (config.color === "mono") {
// violet -> light
var l = 55 + intensity * 40;
return "hsla(258, 85%, " + l + "%, " + a + ")";
}
if (config.color === "dual") {
// blend violet(258) and cyan(188) by phase
var hue = 188 + (258 - 188) * (0.5 + 0.5 * Math.sin(phase));
return "hsla(" + hue.toFixed(0) + ", 85%, " + (58 + intensity * 32) + "%, " + a + ")";
}
// spectrum: hue rotates with phase + intensity
var h = ((phase * 40) + intensity * 60) % 360;
return "hsla(" + h.toFixed(0) + ", 90%, " + (58 + intensity * 30) + "%, " + a + ")";
}
// ---- main loop -----------------------------------------------------------
function frame(now) {
var dt = Math.min(now - lastFrame, 50);
lastFrame = now;
// fps meter (smoothed)
fpsAccum += dt;
fpsFrames++;
if (fpsAccum >= 500) {
fps = Math.round((fpsFrames * 1000) / fpsAccum);
if (statFps) statFps.textContent = fps;
fpsAccum = 0;
fpsFrames = 0;
}
// auto ripple scheduling
if (!reduced) {
autoTimer += dt;
if (autoTimer > 2600) {
autoTimer = 0;
addRipple(Math.random() * W, Math.random() * H);
}
}
ctx.clearRect(0, 0, W, H);
var time = now * 0.001 * config.speed;
var baseR = config.radius;
var idleAmp = (config.idle && !reduced) ? 1 : 0;
// prune dead ripples
for (var i = ripples.length - 1; i >= 0; i--) {
if (now - ripples[i].t0 > ripples[i].life) ripples.splice(i, 1);
}
var maxR = 3.4; // px base dot radius
var ease = 1 - Math.pow(0.001, dt / 1000); // frame-rate independent easing
for (var d = 0; d < dots.length; d++) {
var dot = dots[d];
var target = 0.14; // baseline scale
// continuous idle wave (diagonal traveling sine)
if (idleAmp) {
var wave = Math.sin((dot.x + dot.y) * 0.012 - time * 1.6);
target += (0.5 + 0.5 * wave) * 0.28;
}
// pointer halo
if (pointer.active) {
var pdx = dot.x - pointer.x;
var pdy = dot.y - pointer.y;
var pd = Math.sqrt(pdx * pdx + pdy * pdy);
if (pd < baseR) {
var pf = 1 - pd / baseR;
target += pf * pf * 0.9;
}
}
// click ripples (expanding rings)
for (var ri = 0; ri < ripples.length; ri++) {
var rp = ripples[ri];
var age = (now - rp.t0) / rp.life; // 0..1
var ringR = age * rp.reach * 0.62;
var rdx = dot.x - rp.x;
var rdy = dot.y - rp.y;
var rd = Math.sqrt(rdx * rdx + rdy * rdy);
var band = 46; // ring thickness in px
var diff = Math.abs(rd - ringR);
if (diff < band) {
var rf = (1 - diff / band) * (1 - age);
target += rf * rf * 1.15;
}
}
if (target > 1.35) target = 1.35;
// ease current scale toward target
dot.s += (target - dot.s) * ease;
var intensity = Math.min(1, dot.s);
var radius = maxR * (0.55 + intensity * 1.15);
var phase = (dot.x + dot.y) * 0.01 + time;
ctx.beginPath();
ctx.arc(dot.x, dot.y, radius, 0, Math.PI * 2);
ctx.fillStyle = dotColor(intensity, phase);
ctx.fill();
// glow for hot dots
if (intensity > 0.72) {
ctx.beginPath();
ctx.arc(dot.x, dot.y, radius * 2.1, 0, Math.PI * 2);
ctx.fillStyle = dotColor(intensity, phase).replace(/[\d.]+\)$/, (0.08 * intensity).toFixed(3) + ")");
ctx.fill();
}
}
requestAnimationFrame(frame);
}
// ---- controls wiring -----------------------------------------------------
function bindRange(id, outId, fmt, key, rebuild) {
var input = document.getElementById(id);
var out = document.getElementById(outId);
function apply() {
var v = parseFloat(input.value);
config[key] = v;
out.textContent = fmt(v);
if (rebuild) buildGrid();
}
input.addEventListener("input", apply);
apply();
}
bindRange("spacing", "spacing-out", function (v) { return v + "px"; }, "spacing", true);
bindRange("speed", "speed-out", function (v) { return v.toFixed(1) + "×"; }, "speed", false);
bindRange("radius", "radius-out", function (v) { return v + "px"; }, "radius", false);
var segBtns = document.querySelectorAll(".seg-btn");
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");
config.color = btn.getAttribute("data-color");
});
});
var idleBtn = document.getElementById("idle");
idleBtn.addEventListener("click", function () {
config.idle = !config.idle;
idleBtn.classList.toggle("is-on", config.idle);
idleBtn.setAttribute("aria-checked", config.idle ? "true" : "false");
});
document.getElementById("burst").addEventListener("click", function () {
// fire a small cluster of ripples from the center
var cx = W / 2, cy = H / 2;
addRipple(cx, cy);
setTimeout(function () { addRipple(cx, cy); }, 140);
setTimeout(function () { addRipple(cx, cy); }, 300);
});
// reduced motion handling
function syncReduced() {
reduced = reduceQuery.matches;
if (reducedNote) reducedNote.hidden = !reduced;
if (reduced) {
config.idle = false;
idleBtn.classList.remove("is-on");
idleBtn.setAttribute("aria-checked", "false");
}
}
if (reduceQuery.addEventListener) {
reduceQuery.addEventListener("change", syncReduced);
} else if (reduceQuery.addListener) {
reduceQuery.addListener(syncReduced);
}
syncReduced();
// ---- boot ----------------------------------------------------------------
resize();
requestAnimationFrame(frame);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dot Grid Ripple</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@500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage">
<canvas id="grid" class="grid-canvas" aria-label="Interactive dot grid. Move the pointer to disturb the wave, click to send a ripple."></canvas>
<div class="overlay">
<header class="hero">
<span class="eyebrow">Canvas 2D · DPR-aware</span>
<h1>Dot Grid Ripple</h1>
<p>Move your pointer to bend the wave. Click anywhere to launch a ripple.</p>
</header>
</div>
<aside class="panel" aria-label="Controls">
<div class="panel-head">
<h2>Controls</h2>
<span class="dot-pulse" aria-hidden="true"></span>
</div>
<div class="control">
<label for="spacing">Dot spacing <output id="spacing-out">30px</output></label>
<input id="spacing" type="range" min="18" max="60" step="2" value="30" />
</div>
<div class="control">
<label for="speed">Wave speed <output id="speed-out">1.0×</output></label>
<input id="speed" type="range" min="0" max="3" step="0.1" value="1" />
</div>
<div class="control">
<label for="radius">Cursor radius <output id="radius-out">150px</output></label>
<input id="radius" type="range" min="60" max="320" step="10" value="150" />
</div>
<div class="control">
<span class="control-label">Color mode</span>
<div class="seg" role="radiogroup" aria-label="Color mode">
<button type="button" class="seg-btn is-active" data-color="mono" role="radio" aria-checked="true">Mono</button>
<button type="button" class="seg-btn" data-color="dual" role="radio" aria-checked="false">Dual</button>
<button type="button" class="seg-btn" data-color="spectrum" role="radio" aria-checked="false">Spectrum</button>
</div>
</div>
<div class="control control-toggle">
<label for="idle">Idle wave</label>
<button id="idle" type="button" class="switch is-on" role="switch" aria-checked="true">
<span class="knob"></span>
</button>
</div>
<button id="burst" type="button" class="burst-btn">Ripple burst</button>
<dl class="stats">
<div><dt>Dots</dt><dd id="stat-dots">—</dd></div>
<div><dt>FPS</dt><dd id="stat-fps">—</dd></div>
</dl>
</aside>
<p id="reduced-note" class="reduced-note" hidden>Reduced motion is on — idle wave paused. Interactions still work.</p>
</main>
<script src="script.js"></script>
</body>
</html>Dot Grid Ripple is an ambient background surface built on the Canvas 2D API. A regular lattice of dots is rendered every frame, and each dot computes a target scale and brightness from three overlapping fields: a slow continuous sine wave that keeps the grid gently breathing, a soft radial falloff around the pointer, and any number of expanding click ripples. The values ease toward their targets so motion always looks smooth rather than snapping.
Interactions are immediate. Move the pointer to push a glowing halo across the grid, click (or tap) anywhere to launch a ripple that races outward and fades, and use the control panel to change dot spacing, wave speed, cursor radius and the color mode (monochrome accent, dual-tone, or a hue that shifts with wave phase). A stats readout reports the live dot count and frame rate.
The demo showcases device-pixel-ratio-aware canvas rendering: the backing store is scaled by devicePixelRatio and re-provisioned on resize via ResizeObserver, keeping dots pin-sharp on retina displays. It also respects prefers-reduced-motion, freezing the idle wave and disabling auto-ripples for users who opt out, while keeping the surface fully interactive.