← Hover & cursor

Link Underline Slide

Hover a link and an underline slides in from the left. Move away and it doesn’t shrink back the way it came: it carries on out to the right, as if it were passing through. Keyboard focus draws it too.

Work About Contact

It works on links in running text too: read the docs or say hello.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-link-underline-slide 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-thicknessLine thickness, in px. Default 2, or thicker on big text.
data-offsetGap between the text and the line, in px. Default 2.
data-autoplayLinks that share a parent take turns being hovered while the pointer isn’t on any. Handy for demos.

It replaces the link’s normal underline.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Link Underline Slide: hover a link and an underline slides in from the
    // left; move away and it carries on out to the right, rather than shrinking
    // back the way it came. Keyboard focus draws it too.
    // Usage: <a href="/work" data-link-underline-slide>Work</a>
    // Optional: data-color (line colour, default the link's own colour; two
    // comma-separated colours make a gradient), data-thickness (px, default
    // 2, or 0.08em on big text), data-offset (gap below the text in px,
    // default 2), data-autoplay (when several links share a parent, hover them
    // in turn while the pointer isn't on any; handy for demos and touch screens).
    var DEFAULTS = { thickness: 2, offset: 2 };

    var css =
      '.lus{position:relative;text-decoration:none}' +
      '.lus-line{position:absolute;left:0;right:0;border-radius:99px;pointer-events:none;transform-origin:0 50%;transform:scaleX(0)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var d = el.dataset;
      el.classList.add('lus');
      if (getComputedStyle(el).display === 'inline') el.style.display = 'inline-block';
      var line = document.createElement('span');
      line.className = 'lus-line';
      line.setAttribute('aria-hidden', 'true');
      var fs = parseFloat(getComputedStyle(el).fontSize);
      var thick = d.thickness !== undefined ? +d.thickness : Math.max(DEFAULTS.thickness, Math.round(fs * 0.08));
      var colors = (d.color || 'currentColor').split(',');
      line.style.height = thick + 'px';
      line.style.bottom = -(d.offset !== undefined ? +d.offset : DEFAULTS.offset) - thick + 'px';
      line.style.background = colors.length > 1 ? 'linear-gradient(90deg,' + colors.join(',') + ')' : colors[0];
      el.appendChild(line);

      // The line runs from 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.transform = 'translateX(' + (span.l * 100).toFixed(2) + '%) scaleX(' + Math.max(0, span.r - span.l).toFixed(4) + ')';
      }
      function enter() {
        if (on) return;
        on = true;
        if (anim) anim.pause();
        // Gone (or nearly): start again from the left edge.
        if (span.l > 0.9) span.l = span.r = 0;
        anim = anime.animate(span, { l: 0, r: 1, duration: 450, ease: 'out(4)', onUpdate: draw });
      }
      function leave() {
        if (!on) return;
        on = false;
        if (anim) anim.pause();
        anim = anime.animate(span, {
          l: 1,
          r: 1,
          duration: 450,
          ease: 'inOut(3)',
          onUpdate: draw,
          onComplete: function () { span.l = span.r = 0; draw(); }
        });
      }
      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { el._lusReal = true; enter(); } });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { el._lusReal = false; leave(); } });
      el.addEventListener('focus', enter);
      el.addEventListener('blur', leave);
      return { el: el, enter: enter, leave: leave };
    }

    // Links that share a parent take turns being "hovered".
    function autoplay(group) {
      var i = 0, timer = null, visible = false, cur = null;
      function busy() { return group.some(function (g) { return g.el._lusReal || g.el === document.activeElement; }); }
      function release() { if (cur) { cur.leave(); cur = null; } }
      function step() {
        if (!visible) return;
        if (!busy()) {
          cur = group[i++ % group.length];
          cur.enter();
          var mine = cur;
          setTimeout(function () { if (cur === mine) release(); }, 1100);
        }
        timer = setTimeout(step, 1500);
      }
      // A real pointer or focus takes over straight away.
      group.forEach(function (g) {
        g.el.addEventListener('pointerenter', function () { if (cur !== g) release(); else cur = null; });
        g.el.addEventListener('focus', function () { if (cur !== g) release(); else cur = null; });
      });
      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() {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      var groups = [];
      document.querySelectorAll('[data-link-underline-slide]').forEach(function (el) {
        if (el._lus) return;
        el._lus = true;
        var link = setup(el);
        var auto = el.dataset.autoplay;
        if (auto === undefined || auto === 'false') 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="/work" data-link-underline-slide>Work</a>
  <a href="/about" data-link-underline-slide>About</a>
  <a href="/contact" data-link-underline-slide data-color="#a855f7,#ec4899">Contact</a>
</nav>