← Buttons

Arrow Slide Button

Hover the button and its label slips out to the side as an arrow slides in to take its place. Or, with data-mode="nudge", the label just steps aside to make room for an arrow beside it. The button keeps its size throughout.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-arrow-slide-button to a button or link.

Options

Add any of these attributes to the same element.

data-modeswap (the arrow replaces the label) or nudge (the label steps aside for it). Default swap.
data-durationHow long the slide takes. Default 420 ms.
data-autoplayIt slides by itself every couple of seconds while in view. Handy for demos.

Keyboard focus sets it off too, and on touch screens a tap shows the arrow for a moment. The arrow uses the text colour.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Arrow Slide Button: hover the button and its label slides aside as an
    // arrow slides in. By default the label slips out to the right and the
    // arrow takes its place in the middle; with data-mode="nudge" the label
    // just steps left to make room for an arrow beside it. Keyboard focus and
    // taps set it off too.
    // Usage: <button data-arrow-slide-button>Continue</button>
    // Optional: data-mode ("swap", the default, or "nudge"), data-duration (ms,
    // default 420), data-autoplay (it slides by itself every couple of seconds
    // while in view; handy for demos).
    // The button keeps its size throughout. With reduced motion it swaps
    // instantly.
    var DEFAULTS = { duration: 420, every: 2600 };
    var ARROW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' +
      '<path d="M4 12h15M13 6l6 6-6 6"/></svg>';

    var css =
      '.asb{position:relative;overflow:hidden}' +
      '.asb-label{display:inline-block}' +
      '.asb-arrow{position:absolute;top:50%;left:50%;width:1.25em;height:1.25em;margin:-.625em 0 0 -.625em;opacity:0;pointer-events:none}' +
      '.asb-arrow svg{display:block;width:100%;height:100%}' +
      '.asb-nudge .asb-arrow{left:auto;right:var(--asb-pad);margin-left:0}';
    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 nudge = d.mode === 'nudge';
      var duration = reduced() ? 0 : +d.duration || DEFAULTS.duration;
      el.classList.add('asb');
      if (nudge) el.classList.add('asb-nudge');

      var label = document.createElement('span');
      label.className = 'asb-label';
      while (el.firstChild) label.appendChild(el.firstChild);
      el.appendChild(label);
      var arrow = document.createElement('span');
      arrow.className = 'asb-arrow';
      arrow.setAttribute('aria-hidden', 'true');
      arrow.innerHTML = ARROW;
      el.appendChild(arrow);
      el.style.setProperty('--asb-pad', parseFloat(getComputedStyle(el).paddingRight) * 0.55 + 'px');

      var out = false;
      function slide(on) {
        if (on === out) return;
        out = on;
        var w = el.offsetWidth, a = arrow.offsetWidth;
        var ease = on ? 'out(3)' : 'inOut(2)';
        if (nudge) {
          anime.animate(label, { x: on ? -a * 0.6 : 0, duration: duration, ease: ease });
          anime.animate(arrow, {
            x: on ? [-a, 0] : -a * 0.6, opacity: on ? 1 : 0,
            duration: duration, delay: on ? duration * 0.15 : 0, ease: ease
          });
        } else {
          // Label exits right, arrow arrives from the left; reversed on the way back.
          anime.animate(label, {
            x: on ? w * 0.55 : [-w * 0.55, 0], opacity: on ? 0 : [0, 1],
            duration: duration, delay: on ? 0 : duration * 0.15, ease: ease
          });
          anime.animate(arrow, {
            x: on ? [-w * 0.5, 0] : w * 0.5, opacity: on ? 1 : 0,
            duration: duration, delay: on ? duration * 0.15 : 0, ease: ease
          });
        }
      }
      function busy() { return el.matches(':hover') || document.activeElement === el; }

      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') slide(true); });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch' && document.activeElement !== el) slide(false); });
      el.addEventListener('focus', function () { slide(true); });
      el.addEventListener('blur', function () { if (!el.matches(':hover')) slide(false); });
      var touchTimer = null;
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        clearTimeout(touchTimer);
        slide(true);
        touchTimer = setTimeout(function () { slide(false); }, 1200);
      });

      if (!reduced() && d.autoplay !== undefined && d.autoplay !== 'false') {
        var timer = null;
        var pass = function () {
          if (!busy()) {
            slide(true);
            setTimeout(function () { if (!busy()) slide(false); }, DEFAULTS.every * 0.5);
          }
          timer = setTimeout(pass, DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(pass, 500);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-arrow-slide-button]').forEach(function (el) {
        if (el._asb) return;
        el._asb = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<a class="button" href="/checkout" data-arrow-slide-button>Continue</a>

<!-- the label steps aside instead: -->
<a class="button" href="/step-2" data-arrow-slide-button data-mode="nudge">Next step</a>