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 "Grid overlay explainer" for my site using the Enrichr Library element at https://enrichr.dev/elements/grid-overlay-explainer.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/grid-overlay-explainer.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:
- columns: 12
- gutter: 16
- margin: 24
- maxOpacity: 1
- manual: 0
- bandColor: #ff5a36
- blockColor: #c6f26b
- textColor: #10261f
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/grid-overlay-explainer.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-grid">
<div class="ef-grid__layout"></div>
<div class="ef-grid__bands" aria-hidden="true"></div>
<span class="ef-grid__tag" aria-hidden="true"></span>
</div>CSS
.ef-grid { position: relative; padding: 0 var(--ef-margin, 24px); min-height: 14rem; --ef-cols: 12; --ef-gutter: 16px; }
.ef-grid__layout, .ef-grid__bands { display: grid; grid-template-columns: repeat(var(--ef-cols), minmax(0, 1fr)); gap: var(--ef-gutter); }
.ef-grid__layout { position: relative; z-index: 1; padding: 1.2rem 0; row-gap: var(--ef-gutter); }
.ef-grid__block { border-radius: 0.5rem; min-height: 3.4rem; display: flex; align-items: flex-end; padding: 0.5rem 0.7rem; font-size: 0.8rem; font-weight: 600; color: var(--ef-ink, #10261f); background: var(--ef-block, #c6f26b); overflow: hidden; }
.ef-grid__bands { position: absolute; z-index: 2; inset: 0 var(--ef-margin, 24px); pointer-events: none; opacity: var(--ef-o, 1); }
.ef-grid__band { background: color-mix(in srgb, var(--ef-band-hex, #ff5a36) 32%, transparent); border-inline: 1px solid var(--ef-band-hex, #ff5a36); }
.ef-grid__tag { position: absolute; z-index: 3; right: var(--ef-margin, 24px); top: 0.3rem; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 100rem; background: #fff; color: #000; opacity: var(--ef-o, 1); }JavaScript
// Grid overlay explainer: column and gutter bands fade in over a live layout as it scrolls into view, to show how the grid is built.
// The layout and the bands read the same CSS custom properties, so they can never disagree.
// mount(root, { columns (4-12), gutter (px), margin (px), maxOpacity (0-1), manual (0 = follow scroll, 1-100 = fixed %), bandColor, blockColor, textColor })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-grid");
var layout = root.querySelector(".ef-grid__layout");
var bands = root.querySelector(".ef-grid__bands");
var tag = root.querySelector(".ef-grid__tag");
var opts = {}, raf = 0;
// block spans for a 12 column grid; scaled down for fewer columns
var SPANS = [[12, "Header"], [8, "Main"], [4, "Aside"], [4, "A"], [4, "B"], [4, "C"]];
function render() {
var cols = Math.max(2, Math.min(12, opts.columns));
box.style.setProperty("--ef-cols", cols);
box.style.setProperty("--ef-gutter", opts.gutter + "px");
box.style.setProperty("--ef-margin", opts.margin + "px");
box.style.setProperty("--ef-band-hex", opts.bandColor);
box.style.setProperty("--ef-block", opts.blockColor);
box.style.setProperty("--ef-ink", opts.textColor);
layout.textContent = ""; bands.textContent = "";
SPANS.forEach(function (s) {
var d = document.createElement("div");
d.className = "ef-grid__block";
d.style.gridColumn = "span " + Math.max(1, Math.round((s[0] / 12) * cols));
d.textContent = s[1];
layout.appendChild(d);
});
for (var i = 0; i < cols; i++) { var b = document.createElement("div"); b.className = "ef-grid__band"; bands.appendChild(b); }
tag.textContent = cols + " columns · " + opts.gutter + "px gutter · " + opts.margin + "px margin";
layout.setAttribute("role", "group");
layout.setAttribute("aria-label", "Example layout on a " + cols + " column grid");
paint();
}
function paint() {
raf = 0;
var p = 1;
if (!reduce) {
if (opts.manual > 0) p = opts.manual / 100;
else { var r = box.getBoundingClientRect(), vh = window.innerHeight || 800; p = Math.min(1, Math.max(0, (vh * 0.9 - r.top) / (vh * 0.5))); }
}
box.style.setProperty("--ef-o", String((p * opts.maxOpacity).toFixed(3)));
}
function schedule() { if (!raf) raf = requestAnimationFrame(paint); }
function update(next) {
opts = Object.assign({ columns: 12, gutter: 16, margin: 24, maxOpacity: 1, manual: 0, bandColor: "#ff5a36", blockColor: "#c6f26b", textColor: "#10261f" }, opts, next || {});
render();
}
update(o);
if (!reduce) { window.addEventListener("scroll", schedule, { passive: true }); window.addEventListener("resize", schedule); }
return { update: update, destroy: function () { cancelAnimationFrame(raf); window.removeEventListener("scroll", schedule); window.removeEventListener("resize", schedule); } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"columns":12,"gutter":16,"margin":24,"maxOpacity":1,"manual":0,"bandColor":"#ff5a36","blockColor":"#c6f26b","textColor":"#10261f"});How it works
The layout and the overlay are two CSS grids that read the same custom properties for column count, gutter and margin, so the bands always sit exactly on the columns and cannot drift out of sync. Scroll progress sets one more property, the overlay opacity, so the bands fade in as the layout moves into view. Changing the columns control changes the grid for both at once.
Accessibility and performance
The bands are decorative and hidden from assistive technology; the example layout is a labelled group. With reduced motion the overlay is fully visible and does not fade. Do not rely on colour alone to explain the grid in real documentation.
One bounding-rect read per scroll frame and one custom property write. No animation library and no layout changes while it runs.
| Section | Full page |
|---|---|
| Techniques | Scroll-driven |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
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 overlay my real page grid?
Yes. Give your page container the same custom properties and place the bands element over it. The bands only need the column count, gutter and margin.
Why use CSS variables for both?
A shared source of truth means the explanation can never show a different grid from the one the layout uses.
Does it work for fewer than 12 columns?
Yes. The example blocks scale their spans to the column count you choose.