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 "Scroll mask reveal with moving title" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-mask-title-reveal.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-mask-title-reveal.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:
- accent: #e6ff55
- shape: circle
- roughness: 50
- noise: 0.03
- brighten: 0.6
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/scroll-mask-title-reveal.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-smt">
<svg class="ef-smt__defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<symbol id="ef-smt-p1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></symbol>
<symbol id="ef-smt-p4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/></symbol>
<symbol id="ef-smt-p8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></symbol>
</defs>
</svg>
<div class="ef-smt__view" tabindex="0" role="region" aria-label="Scroll to reveal each image through a rough circular mask"></div>
<div class="ef-smt__hint" aria-hidden="true"><span></span>Scroll</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/OnScrollFilter (MIT) — Copyright (c) 2009 - 2022 [Codrops](https://tympanus.net/codrops) */
.ef-smt { position: relative; width: 100%; height: 100%; min-height: 18rem; overflow: hidden; container-type: inline-size; background: #0c1e18; color: #fbf8f1; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-smt__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-smt__view { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; scrollbar-width: none; outline: none; }
.ef-smt__view::-webkit-scrollbar { display: none; }
.ef-smt__view:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-acc, #e6ff55); }
.ef-smt__sec { position: relative; height: calc(var(--ef-h, 400px) * 2.3); }
.ef-smt__sec + .ef-smt__sec { margin-top: 0; }
.ef-smt__stage { position: sticky; top: 0; height: var(--ef-h, 400px); box-sizing: border-box; padding: 6% 7% 5%; display: flex; flex-direction: column; gap: 3%; overflow: hidden; }
.ef-smt__title { margin: 0; display: flex; justify-content: space-between; align-items: baseline; font-size: clamp(1.8rem, 10cqi, 4.2rem); line-height: 0.95; font-weight: 800; letter-spacing: -0.03em; text-transform: uppercase; }
.ef-smt__title span { display: inline-block; will-change: transform; }
.ef-smt__title span:last-child { color: var(--ef-acc, #e6ff55); }
.ef-smt__art-wrap { flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center; }
.ef-smt__art { display: block; height: 100%; max-width: 100%; aspect-ratio: 4 / 3; border-radius: 0.7rem; background: rgba(251, 248, 241, 0.05); box-shadow: inset 0 0 0 1px rgba(251, 248, 241, 0.14); }
.ef-smt__cap { margin: 0; font-size: 0.82rem; line-height: 1.35; max-width: 34ch; opacity: 0.85; align-self: center; text-align: center; }
.ef-smt__hint { position: absolute; left: 50%; bottom: 0.7rem; transform: translateX(-50%); display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0.8rem; border-radius: 99px; background: rgba(12, 30, 24, 0.82); color: #fbf8f1; font-size: 0.68rem; letter-spacing: 0.16em; text-transform: uppercase; pointer-events: none; transition: opacity 400ms; }
.ef-smt__hint span { width: 6px; height: 6px; border-radius: 50%; background: var(--ef-acc, #e6ff55); }
.ef-smt__hint.is-gone { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.ef-smt__title span { will-change: auto; }
.ef-smt__hint { transition: none; }
}JavaScript
// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/OnScrollFilter (MIT) — Copyright (c) 2009 - 2022 [Codrops](https://tympanus.net/codrops)
// Scroll mask title reveal: as you scroll a section, a two-word title glides from a centred line to its spread-out layout position
// (a hand-made FLIP, measured then replayed with transforms) while an SVG mask grows from nothing and reveals the artwork through a rough,
// noise-displaced edge. Own scroll container, scroll-linked and smoothed with a lerp, no libraries. Reduced motion: no smoothing, no roughness.
// mount(root, { accent, shape ("circle" | "square"), roughness (0-80), noise (0.005-0.12), brighten (0-1) })
function mount(root, o) {
mount.n = (mount.n || 0) + 1;
var uid = "ef-smt-" + mount.n;
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var view = root.querySelector(".ef-smt__view");
var hint = root.querySelector(".ef-smt__hint");
var NS = "http://www.w3.org/2000/svg";
var opts = {}, secs = [], cur = 0, tgt = 0, raf = 0, last = 0, H = 400, ro = null, destroyed = false, offs = [];
var DATA = [
["Green", "House", "Light pours through a quiet room of leaves, one pane at a time."],
["Slow", "Burn", "A single ember keeps the whole evening warm and unhurried."],
["Deep", "Field", "Open ground, long grass, and a horizon that never quite arrives."]
];
function el(name, attrs, parent) {
var n = document.createElementNS(NS, name);
for (var k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function art(i) {
var P = ["p4", "p1", "p8"][i], a = opts.accent;
var cols = [["#005236", "#0c1e18", a], ["#c6f26b", "#2f7d5b", "#0c1e18"], ["#fbf8f1", "#c6f26b", "#0a6b46"]][i];
var svg = el("svg", { "class": "ef-smt__art", viewBox: "0 0 400 300", "aria-hidden": "true", focusable: "false" });
var defs = el("defs", {}, svg);
var gr = el("linearGradient", { id: uid + "-g" + i, x1: "0", y1: "0", x2: "1", y2: "1" }, defs);
el("stop", { offset: "0", "stop-color": cols[0] }, gr); el("stop", { offset: "1", "stop-color": cols[1] }, gr);
var f = el("filter", { id: uid + "-f" + i, filterUnits: "userSpaceOnUse", x: "-120", y: "-120", width: "640", height: "540" }, defs);
var turb = el("feTurbulence", { type: "fractalNoise", baseFrequency: "0.03", numOctaves: "3", result: "n" }, f);
var disp = el("feDisplacementMap", { "in": "SourceGraphic", in2: "n", scale: "50", xChannelSelector: "R", yChannelSelector: "G" }, f);
var mk = el("mask", { id: uid + "-m" + i, maskUnits: "userSpaceOnUse", x: "-120", y: "-120", width: "640", height: "540" }, defs);
var shape = el("g", { filter: "url(#" + uid + "-f" + i + ")" }, mk);
var circ = el("circle", { cx: "200", cy: "150", r: "0", fill: "#fff" }, shape);
var sq = el("rect", { x: "200", y: "150", width: "0", height: "0", fill: "#fff" }, shape);
var g = el("g", { mask: "url(#" + uid + "-m" + i + ")" }, svg);
var zoom = el("g", {}, g);
el("rect", { x: "0", y: "0", width: "400", height: "300", fill: "url(#" + uid + "-g" + i + ")" }, zoom);
var u = el("use", { href: "#ef-smt-" + P, x: "70", y: "20", width: "260", height: "260", style: "color:" + cols[2] }, zoom);
if (P === "p4") { u.setAttribute("x", "50"); u.setAttribute("y", "75"); u.setAttribute("width", "300"); u.setAttribute("height", "150"); }
if (P === "p8") { u.setAttribute("x", "125"); u.setAttribute("y", "10"); u.setAttribute("width", "150"); u.setAttribute("height", "280"); }
var br = el("rect", { x: "0", y: "0", width: "400", height: "300", fill: "#fbf8f1", opacity: "0" }, g);
return { svg: svg, turb: turb, disp: disp, circ: circ, sq: sq, zoom: zoom, br: br };
}
function build() {
view.textContent = ""; secs = [];
for (var i = 0; i < DATA.length; i++) {
var d = DATA[i];
var sec = document.createElement("section"); sec.className = "ef-smt__sec";
var st = document.createElement("div"); st.className = "ef-smt__stage";
var h = document.createElement("h2"); h.className = "ef-smt__title";
var a = document.createElement("span"); a.textContent = d[0];
var b = document.createElement("span"); b.textContent = d[1];
h.appendChild(a); h.appendChild(b);
var wrap = document.createElement("div"); wrap.className = "ef-smt__art-wrap";
var ar = art(i); wrap.appendChild(ar.svg);
var cap = document.createElement("p"); cap.className = "ef-smt__cap"; cap.textContent = d[2];
st.appendChild(h); st.appendChild(wrap); st.appendChild(cap); sec.appendChild(st); view.appendChild(sec);
secs.push({ sec: sec, stage: st, a: a, b: b, cap: cap, ar: ar, ax: 0, ay: 0, bx: 0, by: 0 });
}
applyFx();
measure();
}
function applyFx() {
var rough = reduce ? 0 : opts.roughness;
secs.forEach(function (s) {
s.ar.turb.setAttribute("baseFrequency", String(opts.noise));
s.ar.disp.setAttribute("scale", String(rough));
s.ar.br.setAttribute("fill", "#fbf8f1");
s.ar.circ.style.display = opts.shape === "square" ? "none" : "";
s.ar.sq.style.display = opts.shape === "square" ? "" : "none";
});
}
function measure() {
var h = view.clientHeight;
if (h < 40) return;
H = h; view.style.setProperty("--ef-h", H + "px");
secs.forEach(function (s) {
s.a.style.transform = "none"; s.b.style.transform = "none";
var sr = s.stage.getBoundingClientRect(), ra = s.a.getBoundingClientRect(), rb = s.b.getBoundingClientRect();
var gap = parseFloat(getComputedStyle(s.a).fontSize) * 0.3;
var total = ra.width + gap + rb.width;
var x0 = (sr.width - total) / 2, y0 = (sr.height - ra.height) / 2;
s.ax = sr.left + x0 - ra.left; s.ay = sr.top + y0 - ra.top;
s.bx = sr.left + x0 + ra.width + gap - rb.left; s.by = sr.top + y0 - rb.top;
});
render();
}
function render() {
var top = cur;
var sh = opts.shape === "square";
secs.forEach(function (s) {
var range = s.sec.offsetHeight - H;
var p = range > 0 ? clamp((top - s.sec.offsetTop) / range, 0, 1) : 0;
var q = 1 - p;
s.a.style.transform = "translate3d(" + (s.ax * q).toFixed(2) + "px," + (s.ay * q).toFixed(2) + "px,0)";
s.b.style.transform = "translate3d(" + (s.bx * q).toFixed(2) + "px," + (s.by * q).toFixed(2) + "px,0)";
var e = p;
var r = e * 265;
if (sh) { var w = e * 460, hh = e * 345; s.ar.sq.setAttribute("x", (200 - w / 2).toFixed(1)); s.ar.sq.setAttribute("y", (150 - hh / 2).toFixed(1)); s.ar.sq.setAttribute("width", w.toFixed(1)); s.ar.sq.setAttribute("height", hh.toFixed(1)); }
else s.ar.circ.setAttribute("r", r.toFixed(1));
var z = 1 + 0.15 * e;
s.ar.zoom.setAttribute("transform", "translate(200 150) scale(" + z.toFixed(4) + ") translate(-200 -150)");
s.ar.br.setAttribute("opacity", (opts.brighten * 0.35 * e).toFixed(3));
s.cap.style.opacity = (0.15 + 0.85 * clamp(p * 1.6 - 0.2, 0, 1)).toFixed(3);
});
}
function kick() { if (!raf && !destroyed) { last = 0; raf = requestAnimationFrame(tick); } }
function tick(now) {
raf = 0;
var dt = last ? Math.min(48, now - last) : 16.7; last = now;
var diff = tgt - cur;
if (reduce || Math.abs(diff) < 0.2) cur = tgt;
else cur += diff * (1 - Math.pow(0.88, dt / 16.7));
render();
if (cur !== tgt) raf = requestAnimationFrame(tick);
}
function onScroll() { tgt = view.scrollTop; if (tgt > 4) hint.classList.add("is-gone"); kick(); }
view.addEventListener("scroll", onScroll, { passive: true });
if (window.ResizeObserver) { ro = new ResizeObserver(function () { measure(); tgt = view.scrollTop; cur = tgt; render(); }); ro.observe(view); }
else { var rs = function () { measure(); }; window.addEventListener("resize", rs); offs.push(function () { window.removeEventListener("resize", rs); }); }
function update(next) {
var prev = opts;
opts = Object.assign({ accent: "#e6ff55", shape: "circle", roughness: 50, noise: 0.03, brighten: 0.6 }, opts, next || {});
opts.roughness = clamp(Number(opts.roughness), 0, 80);
opts.noise = clamp(Number(opts.noise), 0.005, 0.12);
opts.brighten = clamp(Number(opts.brighten), 0, 1);
root.style.setProperty("--ef-acc", opts.accent);
if (!secs.length || prev.accent !== opts.accent) { var st = view.scrollTop; build(); view.scrollTop = st; tgt = cur = view.scrollTop; }
applyFx();
render();
}
update(o);
measure();
return {
update: update,
destroy: function () {
destroyed = true;
if (raf) cancelAnimationFrame(raf); raf = 0;
view.removeEventListener("scroll", onScroll);
if (ro) ro.disconnect();
offs.forEach(function (f) { f(); }); offs = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","shape":"circle","roughness":50,"noise":0.03,"brighten":0.6});How it works
A vanilla port, no GSAP, Flip or ScrollTrigger. Each section is tall with a sticky stage, and the section's scroll progress from 0 to 1 is read from the element's own scrollTop and smoothed with a lerp. The title is laid out in its final spread-out position; the script measures where a centred line would sit and replays the difference as a transform that shrinks to zero with progress, which is a hand-made FLIP. The artwork sits inside an SVG mask whose circle or rectangle grows with progress, and an feTurbulence plus feDisplacementMap filter on the mask shape roughens its edge. The art also zooms slightly and a cream overlay brightens it.
Accessibility and performance
The scroller is focusable and scrolls with the usual keys, since it is a native overflow container. The titles are real headings and captions stay in the DOM. The artwork is decorative and hidden from assistive tech. Reduced motion removes smoothing and sets the edge roughness to zero, so the mask grows with a clean edge only while the user scrolls.
Per frame there are a few attribute writes per section and the SVG filter runs on the small mask shape only. Roughness and noise detail cost more at large sizes; lower noise octaves or roughness if you scale the artwork to full screen.
| Section | Full page |
|---|---|
| Techniques | Scroll-driven, Reveal and intro |
| Stack | Vanilla JS, CSS |
| 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 | On-Scroll Filter Effect by Codrops on github.com. 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
Why is the title positioned with transforms?
Measuring the final layout once and animating a transform back to the centred position keeps layout stable and only costs a transform per frame.
Can the mask be another shape?
Yes. The mask content is plain SVG, so a path or polygon can replace the circle; the rectangle option shows the idea.
Does it work with photos?
Yes. Replace the gradient and puzzle piece group with an image element; the mask and overlay apply to anything inside it.