← Hover & cursor

Wavy Underline

Hover a link and a squiggly underline draws in beneath it and keeps flowing sideways like a ripple on water. Move away and it slips off to the right. It can also start as a quiet straight line that turns wavy.

Blog Shop Talks

In running text, a quiet underline can turn wavy on hover.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-wavy-underline to any link.

Options

Add any of these attributes to the same element.

data-colorLine colour. Default the link’s own colour. Two comma-separated colours make a gradient.
data-restShow a faint straight underline at rest, which turns wavy on hover.
data-thicknessLine thickness, in px. Default scales with the text, at least 2.
data-amplitudeHeight of the waves, in px. Default scales with the text.
data-wavelengthLength of one wave, in px. Default scales with the text.
data-speedHow long one wave takes to flow past. Default 700 ms.
data-offsetMove the line further down, in px. Default 0.
data-autoplayLinks that share a parent take turns while the pointer isn’t on any. Handy for demos.

It replaces the link’s normal underline. Keyboard focus shows it too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Wavy Underline: hover a link and a squiggly underline draws in beneath it
    // and keeps flowing sideways like a ripple, until the pointer leaves and it
    // slips away to the right.
    // Usage: <a href="/blog" data-wavy-underline>Read the blog</a>
    // Optional: data-color (line colour, default the link's own colour; two
    // comma-separated colours make a gradient), data-thickness (px, default
    // scales with the text, at least 2), data-amplitude (wave height, px,
    // default scales with the text), data-wavelength (px, default scales with
    // the text), data-speed (ms for one wave to flow past, default 700),
    // data-offset (moves the line down, px, default 0), data-rest (show a
    // straight underline at rest that turns wavy on hover), data-autoplay
    // (links that share a parent take turns while the pointer isn't on any;
    // handy for demos and touch screens).
    var DEFAULTS = { speed: 700, offset: 0 };

    var css =
      '.wvu{position:relative;display:inline-block;text-decoration:none}' +
      '.wvu-line,.wvu-rest{position:absolute;left:0;right:0;pointer-events:none}' +
      '.wvu-line{-webkit-mask-repeat:repeat-x;mask-repeat:repeat-x;clip-path:inset(0 100% 0 0);' +
      'animation:wvu-flow var(--wvu-t) linear infinite paused}' +
      '.wvu-run .wvu-line{animation-play-state:running}' +
      '.wvu-rest{opacity:.45;border-radius:9px;transition:opacity .25s ease,transform .35s ease}' +
      '.wvu-on .wvu-rest{opacity:0;transform:scaleY(2)}' +
      '@keyframes wvu-flow{from{-webkit-mask-position:0 0;mask-position:0 0}to{-webkit-mask-position:calc(var(--wvu-w) * -1) 0;mask-position:calc(var(--wvu-w) * -1) 0}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // One wavelength of the squiggle, as an SVG mask that tiles seamlessly.
    function wave(w, a, t) {
      var h = 2 * a + t, c = h / 2;
      var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '">' +
        '<path d="M0 ' + c + ' Q' + w / 4 + ' ' + (c - 2 * a) + ' ' + w / 2 + ' ' + c + ' T' + w + ' ' + c + '" fill="none" stroke="#000" stroke-width="' + t + '"/></svg>';
      return { url: 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '")', h: h };
    }

    function setup(el) {
      var d = el.dataset;
      var fs = parseFloat(getComputedStyle(el).fontSize);
      var t = d.thickness !== undefined ? +d.thickness : Math.max(2, Math.round(fs * 0.06));
      var a = d.amplitude !== undefined ? +d.amplitude : Math.max(2, Math.round(fs * 0.07));
      var w = d.wavelength !== undefined ? +d.wavelength : Math.max(10, Math.round(fs * 0.42));
      var offset = d.offset !== undefined ? +d.offset : DEFAULTS.offset;
      var colors = (d.color || 'currentColor').split(',');
      var paint = colors.length > 1 ? 'linear-gradient(90deg,' + colors.join(',') + ')' : colors[0];
      el.classList.add('wvu');
      el.style.setProperty('--wvu-w', w + 'px');
      el.style.setProperty('--wvu-t', (d.speed !== undefined ? +d.speed : DEFAULTS.speed) + 'ms');

      var m = wave(w, a, t);
      var line = document.createElement('span');
      line.className = 'wvu-line';
      line.setAttribute('aria-hidden', 'true');
      line.style.height = m.h + 'px';
      line.style.top = 'calc(100% + ' + (offset - m.h / 2) + 'px)';
      line.style.background = paint;
      line.style.webkitMaskImage = line.style.maskImage = m.url;
      line.style.webkitMaskSize = line.style.maskSize = w + 'px ' + m.h + 'px';
      el.appendChild(line);

      if (d.rest !== undefined && d.rest !== 'false') {
        var rest = document.createElement('span');
        rest.className = 'wvu-rest';
        rest.setAttribute('aria-hidden', 'true');
        rest.style.height = Math.max(1, t - 1) + 'px';
        rest.style.top = 'calc(100% + ' + (offset - Math.max(1, t - 1) / 2) + 'px)';
        rest.style.background = paint;
        el.appendChild(rest);
      }

      // The wave 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() {
        line.style.clipPath = 'inset(0 ' + ((1 - span.r) * 100).toFixed(2) + '% 0 ' + (span.l * 100).toFixed(2) + '%)';
      }
      function enter() {
        if (on) return;
        on = true;
        if (anim) anim.pause();
        if (span.l > 0) span.l = span.r = 0;
        el.classList.add('wvu-on');
        if (!reduced()) el.classList.add('wvu-run');
        anim = anime.animate(span, { l: 0, r: 1, duration: reduced() ? 0 : 550, ease: 'out(3)', onUpdate: draw });
      }
      function leave() {
        if (!on) return;
        on = false;
        if (anim) anim.pause();
        el.classList.remove('wvu-on');
        anim = anime.animate(span, {
          l: 1,
          r: 1,
          duration: reduced() ? 0 : 500,
          ease: 'inOut(3)',
          onUpdate: draw,
          onComplete: function () { span.l = span.r = 0; draw(); el.classList.remove('wvu-run'); }
        });
      }

      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { el._wvuReal = true; enter(); } });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { el._wvuReal = false; leave(); } });
      // Touch: a tap shows the wave for a moment.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        el._wvuHold = Date.now() + 4000;
        enter();
        setTimeout(leave, 1600);
      });
      el.addEventListener('focus', enter);
      el.addEventListener('blur', leave);
      return { el: el, enter: enter, leave: leave };
    }

    // Links that share a parent take turns.
    function autoplay(group) {
      var i = 0, timer = null, visible = false;
      function busy() {
        return group.some(function (g) {
          return g.el._wvuReal || g.el === document.activeElement || (g.el._wvuHold && Date.now() < g.el._wvuHold);
        });
      }
      function step() {
        if (!visible) return;
        if (!busy()) {
          var cur = group[i++ % group.length];
          cur.enter();
          setTimeout(function () { if (!cur.el._wvuReal && cur.el !== document.activeElement) cur.leave(); }, 1500);
        }
        timer = setTimeout(step, 1900);
      }
      new IntersectionObserver(function (entries) {
        var was = visible;
        visible = entries[0].isIntersecting;
        if (visible && !was) { clearTimeout(timer); timer = setTimeout(step, 400); }
      }).observe(group[0].el.parentElement || group[0].el);
    }

    function init() {
      var groups = [];
      document.querySelectorAll('[data-wavy-underline]').forEach(function (el) {
        if (el._wvu) return;
        el._wvu = true;
        var link = setup(el);
        var auto = el.dataset.autoplay;
        if (auto === undefined || auto === 'false' || reduced()) return;
        var g = groups.filter(function (g) { return g.parent === el.parentElement; })[0];
        if (!g) groups.push((g = { parent: el.parentElement, links: [] }));
        g.links.push(link);
      });
      groups.forEach(function (g) { autoplay(g.links); });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<nav>
  <a href="/blog" data-wavy-underline>Blog</a>
  <a href="/shop" data-wavy-underline data-color="#a855f7,#ec4899">Shop</a>
</nav>

<p>Read <a href="/docs" data-wavy-underline data-rest>the docs</a>.</p>