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 "Mask shape sections" for my site using the Enrichr Library element at https://enrichr.dev/elements/mask-shape-sections.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/mask-shape-sections.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:
- captions: Circle, Diamond, Band
- image:
- height: 256
- manual: 0
- colorA: #2f7d5b
- colorB: #c6f26b
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/mask-shape-sections.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-mask">
<div class="ef-mask__item" data-shape="circle"><div class="ef-mask__img"></div><span class="ef-mask__cap"></span></div>
<div class="ef-mask__item" data-shape="diamond"><div class="ef-mask__img"></div><span class="ef-mask__cap"></span></div>
<div class="ef-mask__item" data-shape="band"><div class="ef-mask__img"></div><span class="ef-mask__cap"></span></div>
</div>CSS
.ef-mask__item { position: relative; height: var(--ef-h, 16rem); margin: 0 0 1rem; overflow: hidden; }
.ef-mask__img { position: absolute; inset: 0; background-size: cover; background-position: center; background-image: linear-gradient(135deg, var(--ef-a, #2f7d5b), var(--ef-b, #c6f26b)); }
.ef-mask__cap { position: absolute; left: 1rem; bottom: 0.8rem; z-index: 1; font-size: 0.85rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #fff; mix-blend-mode: difference; }
.ef-mask__item[data-shape="circle"] .ef-mask__img { clip-path: circle(calc(var(--p, 1) * 75%) at 50% 50%); }
.ef-mask__item[data-shape="diamond"] .ef-mask__img { clip-path: polygon(50% calc(50% - var(--p, 1) * 60%), calc(50% + var(--p, 1) * 60%) 50%, 50% calc(50% + var(--p, 1) * 60%), calc(50% - var(--p, 1) * 60%) 50%); }
.ef-mask__item[data-shape="band"] .ef-mask__img { clip-path: inset(calc((1 - var(--p, 1)) * 48%) 0); }
@media (prefers-reduced-motion: reduce) { .ef-mask__item .ef-mask__img { clip-path: none !important; } }JavaScript
// Mask shape sections: the same image is cropped through a different shape in each section (circle, diamond, band),
// and each shape opens as its section scrolls into view. The shape is a CSS clip-path driven by one custom property, --p.
// mount(root, { image (URL or empty for a gradient), captions (comma separated), height (px), manual (0 = follow scroll, 1-100 = fixed %), colorA, colorB })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-mask");
var items = Array.prototype.slice.call(root.querySelectorAll(".ef-mask__item"));
var opts = {}, raf = 0;
function safeUrl(u) {
try { var x = new URL(u, window.location.href); return /^https?:$/.test(x.protocol) ? x.href : ""; } catch (e) { return ""; }
}
function render() {
var caps = opts.captions.split(",").map(function (c) { return c.trim(); });
var url = opts.image ? safeUrl(opts.image) : "";
box.style.setProperty("--ef-a", opts.colorA);
box.style.setProperty("--ef-b", opts.colorB);
box.style.setProperty("--ef-h", opts.height + "px");
items.forEach(function (it, i) {
it.querySelector(".ef-mask__cap").textContent = caps[i] || "";
it.querySelector(".ef-mask__img").style.backgroundImage = url ? 'url("' + url + '")' : "";
});
paint();
}
function paint() {
raf = 0;
var vh = window.innerHeight || 800;
items.forEach(function (it) {
var p = 1;
if (!reduce) {
if (opts.manual > 0) p = opts.manual / 100;
else {
var r = it.getBoundingClientRect();
var t = Math.min(1, Math.max(0, (vh * 0.95 - r.top) / (vh * 0.6)));
p = t * t * (3 - 2 * t);
}
}
it.style.setProperty("--p", p.toFixed(3));
});
}
function schedule() { if (!raf) raf = requestAnimationFrame(paint); }
function update(next) {
opts = Object.assign({ image: "", captions: "Circle, Diamond, Band", height: 256, manual: 0, colorA: "#2f7d5b", colorB: "#c6f26b" }, 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"), {"captions":"Circle, Diamond, Band","image":"","height":256,"manual":0,"colorA":"#2f7d5b","colorB":"#c6f26b"});How it works
Each section holds one image layer clipped by a CSS clip-path whose size is a function of a single custom property, --p. JavaScript sets --p from 0 to 1 as that section scrolls into view, eased with a smoothstep curve. The circle grows its radius, the diamond grows its four points outward, and the band opens vertically from the middle. Because the shape lives in CSS, adding a new one is a single rule.
Accessibility and performance
The image layer is decorative; give meaningful images real alt text by using an img element in the layer. With reduced motion every shape is fully open and unmasked. Captions use a blend mode, so check their contrast on your own images.
Scroll events are coalesced into one frame, and each frame only sets a custom property per section. Clip-path animates cheaply, but very large images should still be sized sensibly.
| Section | Gallery |
|---|---|
| Techniques | Scroll-driven, Reveal and intro |
| 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 use my own image?
Yes. Paste an image URL into the control, or set the background on the image layer in your own CSS. Only http and https addresses are accepted by the demo.
How do I add another shape?
Add a section with a new data-shape value and write one clip-path rule that uses var(--p) for its size.
Why clip-path and not mask-image?
Clip-path is simpler to animate from one number and has wide support. The same technique works with mask-size when you need soft edges.