Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Honeycomb pulse grid" for my site using the Enrichr Library element at https://enrichr.dev/elements/honeycomb-pulse-grid.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/honeycomb-pulse-grid.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- palette: enrichr
- radius: 36
- gap: 4
- rounding: 4
- pointy: 1
- fill: solid
- motion: radial
- speed: 1
- depth: 25
- glow: 12
- hover: 1
- primary: #1a7a4c
- secondary: #e6ff55
- border: #003d28
- background: #0c1e18
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/honeycomb-pulse-grid.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-hcp"><canvas class="ef-hcp__c" role="img" aria-label="A honeycomb of glowing hexagons that swell and shrink in a wave rolling out from the centre"></canvas></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-hcp { position: relative; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; touch-action: pan-y; }
.ef-hcp .ef-hcp__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }JavaScript
// Enrichr Library · https://enrichr.dev
// Honeycomb pulse grid: a canvas honeycomb of rounded hexagons that swell and shrink in a wave. Each cell's scale is
// 1 + wave x depth, where the wave is sin(distance from centre x 0.012 - time x 3) for the radial ripple (or a diagonal
// sweep, a per-cell shimmer, or one breath for the whole grid), and the same wave slides each cell's colour from the
// primary toward the secondary. Cells near the pointer grow and brighten, and a click sends out a ring that pushes cells
// up as it passes, fading as it spreads. Cells glow in their own colour. The clock runs at the original's pace (0.016 per
// frame at 60 fps, times speed), made frame-rate independent.
// The glow: the original gave every cell its own canvas shadow, which costs one blur per cell per frame. Here each cell's
// silhouette goes onto a half-resolution layer, that layer is blurred once (the same Gaussian a shadow uses) and stretched
// under the grid, and the cells are then drawn sharp on top in the original order. Only cells lit by the pointer or a ring,
// whose glow is wider, keep their own shadow. Browsers without canvas filters fall back to a shadow on every cell.
// Reduced motion draws one still frame of the wave and ignores the pointer. The loop pauses off screen and in hidden tabs.
// mount(root, { palette, radius (cell radius, px), gap (px), rounding (px), pointy (1 pointy top, 0 flat top),
// fill (solid, dualRing, wireframe, dotMatrix), motion (radial, linear, shimmer, breathe, mouse), speed,
// depth (scale pulse, %), glow (px), hover (1 on, 0 off), primary, secondary, border, background })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-hcp");
var canvas = root.querySelector(".ef-hcp__c");
var ctx = canvas.getContext("2d");
var layer = document.createElement("canvas"), lctx = layer.getContext("2d"); // silhouettes, half resolution
var soft = document.createElement("canvas"), sctx = soft.getContext("2d"); // the same, blurred
var canFilter = (function () { try { sctx.filter = "blur(1px)"; var ok = sctx.filter === "blur(1px)"; sctx.filter = "none"; return ok; } catch (e) { return false; } })();
var PALETTES = {
enrichr: { primary: "#1a7a4c", secondary: "#e6ff55", border: "#003d28", background: "#0c1e18" },
lime: { primary: "#d2eb3c", secondary: "#fbf8f1", border: "#085e3d", background: "#0c1e18" },
ember: { primary: "#e54c1e", secondary: "#fbf8f1", border: "#050d0a", background: "#0c1e18" },
violet: { primary: "#4b1a9a", secondary: "#fbf8f1", border: "#050d0a", background: "#0c1e18" },
cream: { primary: "#005236", secondary: "#e6ff55", border: "#fbf8f1", background: "#fbf8f1" }
};
var COLOR_KEYS = ["primary", "secondary", "border", "background"];
var DEF = { palette: "enrichr", radius: 36, gap: 4, rounding: 4, pointy: 1, fill: "solid", motion: "radial", speed: 1, depth: 25, glow: 12, hover: 1,
primary: "#1a7a4c", secondary: "#e6ff55", border: "#003d28", background: "#0c1e18" };
var opts = {}, col = {}, custom = {}, mounted = false;
var raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0, time = 0;
var W = 0, H = 0, dpr = 1, ripples = [], lit = [], mouse = { x: -1000, y: -1000, active: false };
var pts = [{}, {}, {}, {}, {}, {}];
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function rgb(h) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : "000000", 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
function mixColor(a, b, f) {
f = Math.max(0, Math.min(1, f));
return "rgb(" + Math.round(a[0] + f * (b[0] - a[0])) + ", " + Math.round(a[1] + f * (b[1] - a[1])) + ", " + Math.round(a[2] + f * (b[2] - a[2])) + ")";
}
// a hexagon with each corner cut back by the rounding and bridged with a quadratic curve, as in the original
function hexPath(g, cx, cy, radius, corner, pointy) {
var off = pointy ? Math.PI / 6 : 0, i;
for (i = 0; i < 6; i++) { var a = Math.PI / 3 * i + off; pts[i].x = cx + radius * Math.cos(a); pts[i].y = cy + radius * Math.sin(a); }
g.beginPath();
if (corner <= 0) {
g.moveTo(pts[0].x, pts[0].y);
for (i = 1; i < 6; i++) g.lineTo(pts[i].x, pts[i].y);
g.closePath();
return;
}
for (i = 0; i < 6; i++) {
var p1 = pts[i], p2 = pts[(i + 1) % 6], p0 = pts[(i + 5) % 6];
var v1x = p0.x - p1.x, v1y = p0.y - p1.y, v2x = p2.x - p1.x, v2y = p2.y - p1.y;
var l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y), r = Math.min(corner, l1 / 2, l2 / 2);
var ax = p1.x + v1x / l1 * r, ay = p1.y + v1y / l1 * r;
if (i === 0) g.moveTo(ax, ay); else g.lineTo(ax, ay);
g.quadraticCurveTo(p1.x, p1.y, p1.x + v2x / l2 * r, p1.y + v2y / l2 * r);
}
g.closePath();
}
// one cell in the chosen fill mode. With mono set, every part is painted in the cell colour: that is the silhouette a
// canvas shadow would cast (the original's shadow colour is the cell colour for the fill, stroke and inner parts alike).
function cell(g, cx, cy, dR, dRound, color, mono) {
var pointy = opts.pointy, fill = opts.fill;
if (fill === "dualRing") {
hexPath(g, cx, cy, dR, dRound, pointy);
g.strokeStyle = color; g.lineWidth = 3; g.stroke();
hexPath(g, cx, cy, dR * 0.5, dRound * 0.5, pointy);
g.fillStyle = mono ? color : col.secondary; g.fill();
} else if (fill === "wireframe") {
hexPath(g, cx, cy, dR, dRound, pointy);
g.strokeStyle = color; g.lineWidth = 2; g.stroke();
} else if (fill === "dotMatrix") {
hexPath(g, cx, cy, dR, dRound, pointy);
g.strokeStyle = mono ? color : col.border; g.lineWidth = 1; g.stroke();
g.beginPath(); g.arc(cx, cy, Math.max(2, dR * 0.25), 0, Math.PI * 2);
g.fillStyle = color; g.fill();
} else {
hexPath(g, cx, cy, dR, dRound, pointy);
g.fillStyle = color; g.fill();
if (col.border.toLowerCase() !== col.background.toLowerCase()) { g.strokeStyle = mono ? color : col.border; g.lineWidth = 1.5; g.stroke(); }
}
}
function draw() {
var cX = W / 2, cY = H / 2, R = opts.radius, gap = opts.gap, pointy = opts.pointy, motion = opts.motion, amp = opts.depth / 100;
var c1 = rgb(col.primary), c2 = rgb(col.secondary), live = !reduce && opts.hover && mouse.active, glow = opts.glow;
var hs = pointy ? Math.sqrt(3) * R + gap : 1.5 * R + gap * 0.866;
var vs = pointy ? 1.5 * R + gap * 0.866 : Math.sqrt(3) * R + gap;
var cols = Math.ceil(W / hs) + 3, rows = Math.ceil(H / vs) + 3;
var useLayer = canFilter && glow > 0, n = 0, i;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.shadowBlur = 0;
ctx.fillStyle = col.background;
ctx.fillRect(0, 0, W, H);
if (useLayer) { lctx.setTransform(1, 0, 0, 1, 0, 0); lctx.clearRect(0, 0, layer.width, layer.height); lctx.setTransform(layer.width / W, 0, 0, layer.height / H, 0, 0); }
for (var r = -1; r < rows; r++) {
for (var c = -1; c < cols; c++) {
var cx, cy;
if (pointy) { cx = c * hs + (r % 2 !== 0 ? hs / 2 : 0); cy = r * vs; }
else { cx = c * hs; cy = r * vs + (c % 2 !== 0 ? vs / 2 : 0); }
var wave = 0;
if (motion === "radial") wave = Math.sin(Math.hypot(cx - cX, cy - cY) * 0.012 - time * 3);
else if (motion === "linear") wave = Math.sin((cx + cy) * 0.008 - time * 3);
else if (motion === "shimmer") wave = Math.sin((c * 17 + r * 31) * 0.5 + time * 4);
else if (motion === "breathe") wave = Math.sin(time * 2);
var scale = 1 + wave * amp, boost = 0;
if (live) {
var dm = Math.hypot(cx - mouse.x, cy - mouse.y);
if (dm < 180) { boost = Math.pow(1 - dm / 180, 2); scale += boost * 0.25; }
}
for (var k = 0; k < ripples.length; k++) {
var rp = ripples[k], diff = Math.abs(Math.hypot(cx - rp.x, cy - rp.y) - rp.radius);
if (diff < 60) { var rf = (1 - diff / 60) * rp.alpha; scale += rf * 0.35; boost = Math.max(boost, rf); }
}
scale = Math.max(0.1, scale);
var dR = R * scale, dRound = opts.rounding * scale;
var color = mixColor(c1, c2, (wave + 1) / 2 * 0.8 + boost * 0.5);
if (!useLayer) {
// no canvas filters: the original's way, a shadow on every cell
if (glow > 0) { ctx.shadowColor = color; ctx.shadowBlur = glow * (0.5 + boost) * dpr; }
cell(ctx, cx, cy, dR, dRound, color, false);
continue;
}
// cells the pointer or a ring lights up get their own, wider shadow below; the rest glow through the shared layer
if (boost <= 0) cell(lctx, cx, cy, dR, dRound, color, true);
lit[n++] = cx; lit[n++] = cy; lit[n++] = dR; lit[n++] = dRound; lit[n++] = color; lit[n++] = boost;
}
}
if (useLayer) {
// the silhouettes, blurred by the Gaussian a canvas shadow of glow x 0.5 uses (sigma = blur / 2, in layer pixels),
// stretched to full size, then every cell sharp on top in the original order
sctx.setTransform(1, 0, 0, 1, 0, 0);
sctx.clearRect(0, 0, soft.width, soft.height);
sctx.filter = "blur(" + (glow * 0.5 / 2 * layer.width / W) + "px)";
sctx.drawImage(layer, 0, 0);
sctx.filter = "none";
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(soft, 0, 0, canvas.width, canvas.height);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
for (i = 0; i < n; i += 6) {
if (lit[i + 5] > 0) { ctx.shadowColor = lit[i + 4]; ctx.shadowBlur = glow * (0.5 + lit[i + 5]) * dpr; }
else ctx.shadowBlur = 0;
cell(ctx, lit[i], lit[i + 1], lit[i + 2], lit[i + 3], lit[i + 4], false);
}
}
ctx.shadowBlur = 0;
}
function loop(now) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.05, (now - last) / 1000 || 0); last = now;
if (!visible || document.hidden) return;
var f = dt * 60; // the original steps once per frame at 60 fps
time += 0.016 * opts.speed * f;
for (var i = ripples.length - 1; i >= 0; i--) {
var rp = ripples[i];
rp.radius += 8 * opts.speed * f;
rp.alpha -= 0.015 * f;
if (rp.alpha <= 0 || rp.radius > Math.max(W, H) * 1.2) ripples.splice(i, 1);
}
draw();
}
function size() {
var w = Math.max(1, wrap.clientWidth), h = Math.max(1, wrap.clientHeight), nd = Math.min(window.devicePixelRatio || 1, 2);
if (w === W && h === H && nd === dpr) return;
W = w; H = h; dpr = nd;
canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
soft.width = layer.width = Math.max(1, Math.round(W * dpr / 2)); soft.height = layer.height = Math.max(1, Math.round(H * dpr / 2));
draw();
}
function point(e) { var r = wrap.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
function onMove(e) { var p = point(e); mouse.x = p.x; mouse.y = p.y; mouse.active = true; }
function onLeave() { mouse.active = false; }
function onClick(e) { if (reduce) return; var p = point(e); ripples.push({ x: p.x, y: p.y, radius: 0, alpha: 1 }); }
function update(partial) {
partial = partial || {};
opts = Object.assign({}, DEF, opts, partial);
opts.radius = Math.max(4, num(opts.radius, 36)); opts.gap = Math.max(0, num(opts.gap, 4)); opts.rounding = Math.max(0, num(opts.rounding, 4));
opts.pointy = String(opts.pointy) === "1" || opts.pointy === true; opts.hover = String(opts.hover) === "1" || opts.hover === true;
opts.speed = Math.max(0, num(opts.speed, 1)); opts.depth = num(opts.depth, 25); opts.glow = Math.max(0, num(opts.glow, 12));
// colours: the palette sets all four; a colour you pick yourself wins for that role
if (!mounted) { COLOR_KEYS.forEach(function (k) { custom[k] = partial[k] != null && String(partial[k]).toLowerCase() !== DEF[k]; }); }
else {
if ("palette" in partial) custom = {};
COLOR_KEYS.forEach(function (k) { if (k in partial) custom[k] = true; });
}
var pal = PALETTES[opts.palette] || PALETTES.enrichr;
COLOR_KEYS.forEach(function (k) { col[k] = custom[k] ? String(opts[k]) : pal[k]; });
wrap.style.background = col.background;
if (destroyed || !mounted) return;
draw();
}
update(o);
mounted = true;
size();
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerleave", onLeave);
wrap.addEventListener("pointercancel", onLeave);
wrap.addEventListener("click", onClick);
if (!reduce) {
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
last = performance.now(); raf = requestAnimationFrame(loop);
}
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(wrap); }
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerleave", onLeave);
wrap.removeEventListener("pointercancel", onLeave);
wrap.removeEventListener("click", onClick);
if (io) io.disconnect();
if (ro) ro.disconnect();
ripples = []; lit = [];
layer.width = layer.height = soft.width = soft.height = 0;
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"palette":"enrichr","radius":36,"gap":4,"rounding":4,"pointy":1,"fill":"solid","motion":"radial","speed":1,"depth":25,"glow":12,"hover":1,"primary":"#1a7a4c","secondary":"#e6ff55","border":"#003d28","background":"#0c1e18"});How it works
Rounded hexagons are laid out in offset rows (or columns for flat tops), one cell past every edge. A clock adds 0.016 times the speed per frame at 60 frames a second. Each cell's wave value is the sine of (distance from the centre times 0.012 minus clock times 3) for the centre wave, the sine of ((x plus y) times 0.008 minus clock times 3) for the diagonal wave, a per-cell phase for the shimmer, or one sine for the whole grid when breathing. The cell is drawn at 1 plus wave times the depth, so crests overlap their neighbours and troughs open dark gaps, and the same wave slides its colour from the cell colour toward the crest colour. Cells within 180 pixels of the pointer grow by up to 25% and brighten. A click starts a ring that grows 8 pixels a frame and fades by 0.015 a frame; cells within 60 pixels of the ring swell by up to 35%. Each cell glows in its own colour: the silhouettes are drawn to a half-size layer, blurred once with the same Gaussian a canvas shadow uses, laid under the grid, and the cells are drawn sharp on top in the original order. Cells lit by the pointer or a ring keep their own wider shadow.
Accessibility and performance
The canvas is decorative and has a text label. With prefers-reduced-motion it draws one still frame of the wave and ignores the pointer and clicks. At the default speed each wave takes about two seconds to pass, a slow swell with no flashing. Click ripples are a pointer extra; nothing needs the keyboard.
The original gave every cell its own canvas shadow, one blur per cell per frame, and ran at about 21 frames a second at 1280 by 720 in a GPU test. This version blurs all the silhouettes once per frame on a half-size layer and held 60 in the same test. Only cells lit by the pointer or a ring keep their own shadow, and Glow 0 skips the blur entirely. Browsers without canvas filters fall back to per-cell shadows. The pixel ratio is capped at 2 and the loop pauses off screen or in a hidden tab.
| Section | Background |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, Canvas |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Animated Honeycomb Pattern Generator by Enrichr on CodePen. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Can I put content on top of it?
Yes. Give .ef-hcp a height and place your content over it with position: absolute, or stack both in one grid cell. The default is bright, so put text on a solid panel or lower the depth and glow.
What does the palette do?
A palette sets the four colours at once. A colour you pick yourself after that overrides the palette for that one role.
How do I get ripples only on click?
Set Motion to Click ripples only. The grid then rests at full size and each click sends a ring of swelling cells across it.
Does it look exactly like the original?
The motion, sizes and colours follow the same maths. The original drew each cell's shadow over the cells before it, which softened their borders a little; here the shared glow sits under all the cells, so borders are a touch crisper.