← Text reveals

Shape Mask Reveal

The text is uncovered by a shape growing out from a point: a circle, a wobbling blob, a diamond, a star, or an image of your own. Give it a list of shapes and it takes the next one each time it loops.

Grow into view

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-shape-mask-reveal to any heading, text element or image.

Options

Add any of these attributes to the same element.

data-shapecircle (default), blob, diamond or star. A space-separated list takes the next shape on each loop.
data-imageThe URL of your own shape (an SVG or PNG with transparency), used instead of data-shape.
data-originWhere the shape grows from, as x% y%. Default 50% 50%.
data-spinDegrees the shape turns as it grows. Default 0.
data-loopGrow, hold, shrink away and grow again.
data-holdHow long the text stays before shrinking away, when looping. Default 1600 ms.
data-durationTime to grow. Default 1300 ms.
data-delayWait before starting. Default 0 ms.

The shape grows until it covers the whole element, so it reads best on text with a background, like a badge or a banner.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Shape Mask Reveal: text is uncovered by a shape growing out from a point:
    // a circle, a wobbling blob, a diamond, a star, or your own image.
    // Usage: <h1 data-shape-mask-reveal>Grow into view</h1>
    // Optional: data-shape ("circle", the default, "blob", "diamond" or "star";
    // a space-separated list takes the next shape on each loop), data-image (a
    // URL of your own shape, e.g. an SVG, used instead of data-shape), data-origin
    // (where it grows from, as "x% y%", default "50% 50%"), data-spin (degrees the
    // shape turns as it grows, default 0), data-loop (shrink away and grow again),
    // data-hold (ms to stay before shrinking), data-duration (ms to grow),
    // data-delay (ms before starting).
    var DEFAULTS = { shape: 'circle', origin: '50% 50%', spin: 0, duration: 1300, hold: 1600, delay: 0 };

    function reduced() {
      return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    }

    // Each shape as a radius for an angle (1 = the circle it fits round), and how
    // far it must grow before its narrowest part covers that circle.
    var SHAPES = {
      blob: {
        cover: 1.35,
        r: function (a, t) {
          return 1 + 0.13 * Math.sin(3 * a + t * 2.1) + 0.09 * Math.sin(5 * a - t * 2.9) + 0.05 * Math.sin(2 * a + t);
        }
      },
      diamond: {
        cover: 1.42,
        r: function (a) { return 1 / (Math.abs(Math.cos(a)) + Math.abs(Math.sin(a))); }
      },
      star: {
        cover: 2.3,
        r: function (a) {
          // Five points, outer radius 1, inner radius 0.45, straight edges.
          var seg = Math.PI / 5;
          var k = ((a % (2 * seg)) + 2 * seg) % (2 * seg);
          var inner = 0.45;
          var p1 = k < seg ? 1 : inner, p2 = k < seg ? inner : 1;
          var b = k < seg ? k : k - seg;
          // Radius along a straight edge between two points seg apart.
          return (p1 * p2 * Math.sin(seg)) / (p1 * Math.sin(b) + p2 * Math.sin(seg - b));
        }
      }
    };

    function origin(el) {
      var o = (el.dataset.origin || DEFAULTS.origin).split(/\s+/);
      var w = el.offsetWidth, h = el.offsetHeight;
      var x = (parseFloat(o[0]) / 100) * w, y = (parseFloat(o[1] || o[0]) / 100) * h;
      // Far enough to reach the furthest corner.
      var reach = Math.max(Math.hypot(x, y), Math.hypot(w - x, y), Math.hypot(x, h - y), Math.hypot(w - x, h - y));
      return { x: x, y: y, reach: reach };
    }

    function clip(shape, o, p, t, spin) {
      if (shape === 'circle' || !SHAPES[shape]) {
        return 'circle(' + (p * o.reach).toFixed(1) + 'px at ' + o.x.toFixed(1) + 'px ' + o.y.toFixed(1) + 'px)';
      }
      var S = SHAPES[shape], R = p * o.reach * S.cover, rot = (spin * p - 90) * Math.PI / 180;
      var pts = [], n = shape === 'blob' ? 72 : 80;
      for (var i = 0; i < n; i++) {
        var a = (i / n) * Math.PI * 2;
        var r = R * S.r(a, t);
        pts.push((o.x + r * Math.cos(a + rot)).toFixed(1) + 'px ' + (o.y + r * Math.sin(a + rot)).toFixed(1) + 'px');
      }
      return 'polygon(' + pts.join(',') + ')';
    }

    function play(el) {
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var spin = +d.spin || DEFAULTS.spin;
      var shapes = (d.shape || DEFAULTS.shape).split(/\s+/);
      var state = { p: 0 }, turn = 0, start = performance.now();
      var o, shape;

      function draw() {
        var t = (performance.now() - start) / 1000;
        if (d.image) {
          var size = (state.p * o.reach * 2.6).toFixed(1) + 'px';
          el.style.webkitMaskSize = el.style.maskSize = size + ' ' + size;
        } else {
          el.style.clipPath = clip(shape, o, state.p, t, spin);
        }
      }

      function grow() {
        o = origin(el);
        shape = shapes[turn++ % shapes.length];
        if (d.image) {
          var pos = o.x.toFixed(1) + 'px ' + o.y.toFixed(1) + 'px';
          el.style.webkitMaskImage = el.style.maskImage = 'url("' + d.image + '")';
          el.style.webkitMaskRepeat = el.style.maskRepeat = 'no-repeat';
          // Keep the image centred on the origin as it grows.
          el.style.webkitMaskPosition = el.style.maskPosition =
            'calc(' + pos.split(' ')[0] + ' - var(--smr-half, 0px)) calc(' + pos.split(' ')[1] + ' - var(--smr-half, 0px))';
        }
        state.p = 0;
        draw();
        el.style.opacity = '';
        var tl = anime.createTimeline({
          onComplete: function () {
            if (!loop) {
              el.style.clipPath = '';
              ['maskImage', 'webkitMaskImage'].forEach(function (k) { el.style[k] = ''; });
              return;
            }
            setTimeout(grow, 400);
          }
        });
        var upd = function () {
          if (d.image) el.style.setProperty('--smr-half', (state.p * o.reach * 1.3).toFixed(1) + 'px');
          draw();
        };
        tl.add(state, { p: [0, 1], duration: duration, ease: 'inOut(2.4)', onUpdate: upd });
        if (loop) {
          tl.add(state, { p: [1, 0], duration: duration * 0.7, ease: 'in(2.4)', onUpdate: upd },
            '+=' + (+d.hold || DEFAULTS.hold));
        }
      }

      setTimeout(grow, +d.delay || DEFAULTS.delay);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-shape-mask-reveal]').forEach(function (el) {
        if (el._smr) return;
        el._smr = true;
        if (reduced()) return;
        // Hidden with opacity until it plays: the browser won't report a fully
        // clipped element as scrolled into view.
        el.style.opacity = 0;
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-shape-mask-reveal>Grow into view</h1>

<!-- a turning star from the bottom-left corner: -->
<h1 data-shape-mask-reveal data-shape="star" data-spin="90" data-origin="0% 100%">Grow into view</h1>

<!-- your own shape: -->
<h1 data-shape-mask-reveal data-image="/heart.svg">Grow into view</h1>