← Hover & cursor

Text Fill Sweep

Hover the text and colour floods into it from left to right, like ink filling the letters. When the pointer leaves, the colour carries on out to the right. It can fill an outline, too.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-text-fill-sweep to a link, heading or word.

Options

Add any of these attributes to the same element.

data-colorFill colour. Default #a855f7. Several comma-separated colours make a gradient.
data-outlineShow the text as an outline until it fills.
data-reverseDrain the colour back to the left on leave, instead of carrying on.
data-durationHow long the sweep takes. Default 650 ms.
data-autoplayFill and empty on a timer while the pointer isn’t over it. Handy for demos and touch screens.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Text Fill Sweep: hover the text and colour floods into it from left to
    // right. When the pointer leaves, the colour carries on out to the right.
    // Usage: <a href="/work" data-text-fill-sweep>Our work</a>
    // Optional: data-color (fill colour, default #a855f7; two or more
    // comma-separated colours make a gradient), data-outline (the text sits as
    // an outline until it fills), data-reverse (the colour drains back to the
    // left instead of carrying on), data-duration (ms, default 650),
    // data-autoplay (fill and empty on a timer while the pointer isn't over it;
    // handy for demos and touch screens).
    var DEFAULTS = { color: '#a855f7', duration: 650 };

    var css =
      '.tfs{position:relative;display:inline-block;text-decoration:none}' +
      '.tfs-fill{position:absolute;inset:0;pointer-events:none;clip-path:inset(-10% 100% -10% 0)}' +
      '.tfs-grad{-webkit-background-clip:text;background-clip:text;color:transparent!important}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function setup(el) {
      var d = el.dataset;
      var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
      var colors = (d.color || DEFAULTS.color).split(',');
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.classList.add('tfs');
      if (d.outline !== undefined && d.outline !== 'false') {
        var ink = getComputedStyle(el).color;
        var w = Math.max(1, Math.round(parseFloat(getComputedStyle(el).fontSize) / 40));
        el.style.color = 'transparent';
        el.style.webkitTextStroke = w + 'px ' + ink;
      }
      var fill = document.createElement('span');
      fill.className = 'tfs-fill';
      fill.setAttribute('aria-hidden', 'true');
      fill.textContent = text;
      fill.style.webkitTextStroke = '0';
      if (colors.length > 1) {
        fill.classList.add('tfs-grad');
        fill.style.backgroundImage = 'linear-gradient(90deg,' + colors.join(',') + ')';
      } else {
        fill.style.color = colors[0];
      }
      el.appendChild(fill);

      // The colour covers l to r, each 0 (left edge) to 1 (right edge).
      var span = { l: 0, r: 0 };
      var anim = null, on = false;
      function draw() {
        fill.style.clipPath = 'inset(-10% ' + ((1 - span.r) * 100).toFixed(2) + '% -10% ' + (span.l * 100).toFixed(2) + '%)';
      }
      function enter() {
        if (on) return;
        on = true;
        if (anim) anim.pause();
        // Still sweeping out to the right: start again from the left edge.
        if (span.l > 0) span.l = span.r = 0;
        anim = anime.animate(span, { l: 0, r: 1, duration: reduced() ? 0 : duration, ease: 'out(3)', onUpdate: draw });
      }
      function leave() {
        if (!on) return;
        on = false;
        if (anim) anim.pause();
        var back = d.reverse !== undefined && d.reverse !== 'false';
        anim = anime.animate(span, {
          l: back ? 0 : 1,
          r: back ? 0 : 1,
          duration: reduced() ? 0 : duration,
          ease: 'inOut(3)',
          onUpdate: draw,
          onComplete: function () { span.l = span.r = 0; draw(); }
        });
      }

      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { el._tfsReal = true; enter(); } });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { el._tfsReal = false; leave(); } });
      // Touch: a tap fills it, another tap empties it.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        el._tfsHold = Date.now() + 5000;
        if (on) leave();
        else enter();
      });
      el.addEventListener('focus', enter);
      el.addEventListener('blur', leave);
      return { enter: enter, leave: leave, isOn: function () { return on; } };
    }

    function autoplay(el, api, offset) {
      var visible = false, timer = null, mine = false;
      function tick() {
        if (!visible) return;
        var busy = el._tfsReal || el === document.activeElement || (el._tfsHold && Date.now() < el._tfsHold);
        if (busy) { mine = false; timer = setTimeout(tick, 800); return; }
        if (mine) { api.leave(); mine = false; timer = setTimeout(tick, 1200); }
        else if (!api.isOn()) { api.enter(); mine = true; timer = setTimeout(tick, 1500); }
        else timer = setTimeout(tick, 800);
      }
      new IntersectionObserver(function (entries) {
        var was = visible;
        visible = entries[0].isIntersecting;
        if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 400 + offset); }
      }).observe(el);
    }

    function init() {
      var counts = [];
      document.querySelectorAll('[data-text-fill-sweep]').forEach(function (el) {
        if (el._tfs) return;
        el._tfs = true;
        var api = setup(el);
        var auto = el.dataset.autoplay;
        if (auto === undefined || auto === 'false' || reduced()) return;
        var g = counts.filter(function (c) { return c.parent === el.parentElement; })[0];
        if (!g) counts.push((g = { parent: el.parentElement, n: 0 }));
        autoplay(el, api, g.n++ * 600);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<a href="/studio" data-text-fill-sweep>Studio</a>

<!-- an outline that fills with a gradient: -->
<a href="/work" data-text-fill-sweep data-outline data-color="#a855f7,#ec4899">Work</a>