← Scroll

Clip-path Reveal

An image is uncovered by a shape that grows as you scroll, a circle opening from the middle by default, until the whole picture shows. Tied to the scroll, so it closes again if you scroll back. Try a diamond, a rounded rectangle or a wipe.

Scroll inside ↓

data-shape="circle"

data-shape="diamond"

data-shape="inset"

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-clip-path-reveal to an image, video or any element.

Options

Add any of these attributes to the same element.

data-shapecircle (default), inset (a rounded rectangle growing from the middle), diamond or wipe (rising from the bottom).
data-originWhere a circle opens from, e.g. 0% 100% for the bottom-left corner. Default the middle.
data-startWhere the element’s top is in the view when the reveal starts, from 0 (top) to 1 (bottom). Default 0.95.
data-endWhere its top is when the reveal finishes. Default 0.25.

Works inside scrolling boxes too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Clip-path Reveal: an image is uncovered by a shape that grows as you
    // scroll, a circle opening from the middle by default, until the whole
    // picture shows. Tied to the scroll, so it closes again if you scroll back.
    // Usage: <img data-clip-path-reveal src="photo.jpg" alt="…">
    //    or: <div data-clip-path-reveal data-shape="diamond">…</div>
    // Optional: data-shape ("circle" (default), "inset" (a rounded rectangle
    // growing from the middle), "diamond", or "wipe" (rising from the bottom)),
    // data-origin (where a circle opens from, default "50% 50%"), data-start and
    // data-end (where the element's top is in the view, 0 top to 1 bottom, as the
    // reveal starts and finishes: default 0.95 and 0.25).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { start: 0.95, end: 0.25, origin: '50% 50%' };

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;
      }
      return null;
    }

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    // The clip for each shape, from t = 0 (nothing showing) to 1 (all of it).
    var SHAPES = {
      // A radius of 71% reaches the corners from the middle; from anywhere else
      // it may need up to 142%.
      circle: function (t, o) { return 'circle(' + (t * (o === DEFAULTS.origin ? 71 : 142)).toFixed(2) + '% at ' + o + ')'; },
      inset: function (t) {
        var i = (1 - t) * 50, r = 24 * (1 - t) + 4;
        return 'inset(' + i.toFixed(2) + '% ' + i.toFixed(2) + '% round ' + r.toFixed(1) + 'px)';
      },
      diamond: function (t) {
        var a = t * 100;
        return 'polygon(50% ' + (50 - a).toFixed(2) + '%, ' + (50 + a).toFixed(2) + '% 50%, 50% ' + (50 + a).toFixed(2) + '%, ' + (50 - a).toFixed(2) + '% 50%)';
      },
      wipe: function (t) {
        // A gently curved edge that flattens as it reaches the top.
        var y = (1 - t) * 100;
        return 'inset(' + y.toFixed(2) + '% 0 0 0 round ' + (40 * (1 - t)).toFixed(1) + '% ' + (40 * (1 - t)).toFixed(1) + '% 0 0)';
      }
    };

    function setup(el) {
      var d = el.dataset;
      var shape = SHAPES[d.shape] || SHAPES.circle;
      var origin = d.origin || DEFAULTS.origin;
      var start = num(d.start, DEFAULTS.start), end = num(d.end, DEFAULTS.end);
      var box = scroller(el);
      var queued = false, last = -1;

      function update() {
        queued = false;
        var r = el.getBoundingClientRect();
        var top = 0, h = window.innerHeight;
        if (box) { var b = box.getBoundingClientRect(); top = b.top + box.clientTop; h = box.clientHeight; }
        var p = (top + h * start - r.top) / (h * (start - end) || 1);
        p = Math.max(0, Math.min(1, p));
        if (Math.abs(p - last) < 0.0005) return;
        last = p;
        // Ease in and out, so the shape opens smoothly and settles fully open.
        var t = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
        el.style.clipPath = t >= 1 ? 'none' : shape(t, origin);
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onScroll);
      update();
    }

    function init() {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      document.querySelectorAll('[data-clip-path-reveal]').forEach(function (el) {
        if (el._cpr) return;
        el._cpr = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<img src="aurora.jpg" alt="Northern lights" data-clip-path-reveal>

<!-- a diamond: -->
<img src="aurora.jpg" alt="Northern lights" data-clip-path-reveal data-shape="diamond">