Copied!
Gallery

Mask shape sections

The same image revealed through a circle, a diamond and a band as each section scrolls into view.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

ScrollElementVanilla JS + CSS

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.

SectionGallery
TechniquesScroll-driven, Reveal and intro
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

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.

Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport. Scroll velocity skewText that leans and splits into two colour fringes in proportion to how fast the page scrolls. Circle reveal introA gate button opens a circle from the click point that grows to reveal the page.

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.