← Attention

Bouncing Arrow Pointer

An arrow appears beside the element and keeps bouncing towards it, tapping at it like a friendly “over here”. It can sit on any side, and get out of the way once the pointer arrives.

Only 12 places left

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-bouncing-arrow-pointer to any element. The arrow appears beside it and bounces while it’s on screen.

Options

Add any of these attributes to the same element.

data-sideWhere the arrow sits: left, right, top or bottom. Default left.
data-sizeHow big the arrow is, in px. Default 28.
data-gapSpace between the arrow and the element, in px. Default 10.
data-distanceHow far each bounce travels, in px. Default 10.
data-durationHow long one bounce takes. Default 700 ms.
data-colorThe arrow’s colour. Default #6a0dad.
data-hoverSet to hide to fade the arrow out while the pointer is over the element or it has focus.

The arrow is added inside the element and hangs off its side, so don’t clip the element’s overflow, and leave room for it. Call animacious.arrowPointer(el, false) to remove it, for example once it’s been clicked, and animacious.arrowPointer(el) to bring it back.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Bouncing Arrow Pointer: an arrow appears beside the element and keeps
    // bouncing towards it, pointing at it, like a "click here" hint. It pauses
    // while off screen.
    // Usage: <button data-bouncing-arrow-pointer>Sign up</button>
    // Optional: data-side (where the arrow sits: "left", default, "right",
    // "top" or "bottom"), data-size (px, default 28), data-gap (px between the
    // arrow and the element, default 10), data-distance (px each bounce
    // travels, default 10), data-duration (ms per bounce, default 700),
    // data-color (default #6a0dad), data-hover="hide" (hide the arrow while the
    // pointer is over the element or it has focus).
    // The arrow is added inside the element, so don't clip its overflow.
    // Script use: animacious.arrowPointer(el, false) removes the arrow (for
    // example once it has been clicked); animacious.arrowPointer(el) brings it back.
    var DEFAULTS = { side: 'left', size: 28, gap: 10, distance: 10, duration: 700, color: '#6a0dad' };

    var css =
      '.bap-arrow{position:absolute;z-index:2;pointer-events:none;line-height:0;transition:opacity .25s}' +
      '.bap-arrow svg{display:block;width:100%;height:100%;overflow:visible;fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}' +
      '.bap-off{opacity: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 side = d.side || DEFAULTS.side;
      var size = +d.size || DEFAULTS.size;
      var gap = d.gap === undefined ? DEFAULTS.gap : +d.gap;
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      var a = document.createElement('span');
      a.className = 'bap-arrow';
      a.setAttribute('aria-hidden', 'true');
      a.style.width = a.style.height = size + 'px';
      a.style.color = d.color || DEFAULTS.color;
      var turn = { left: 0, top: 90, right: 180, bottom: -90 }[side] || 0;
      a.innerHTML = '<svg viewBox="0 0 24 24" style="transform:rotate(' + turn + 'deg)"><path d="M3 12h16M13 6l6 6-6 6"/></svg>';
      var half = -size / 2 + 'px';
      if (side === 'right') { a.style.left = '100%'; a.style.top = '50%'; a.style.marginTop = half; a.style.marginLeft = gap + 'px'; }
      else if (side === 'top') { a.style.bottom = '100%'; a.style.left = '50%'; a.style.marginLeft = half; a.style.marginBottom = gap + 'px'; }
      else if (side === 'bottom') { a.style.top = '100%'; a.style.left = '50%'; a.style.marginLeft = half; a.style.marginTop = gap + 'px'; }
      else { a.style.right = '100%'; a.style.top = '50%'; a.style.marginTop = half; a.style.marginRight = gap + 'px'; }
      el.appendChild(a);

      var s = (el._bap = { arrow: a });
      if (reduced()) return;
      // Towards the element is +x for an arrow on the left, -y for one below, and so on.
      var prop = side === 'top' || side === 'bottom' ? 'y' : 'x';
      var sign = side === 'right' || side === 'bottom' ? -1 : 1;
      var dist = d.distance === undefined ? DEFAULTS.distance : +d.distance;
      var dur = +d.duration || DEFAULTS.duration;
      var back = {}, toward = {};
      back[prop] = -sign * dist;
      toward[prop] = 0;
      // Pulled back slowly, then it darts in and taps the element.
      back.duration = dur * 0.6;
      back.ease = 'out(2)';
      toward.duration = dur * 0.4;
      toward.ease = 'in(3)';
      s.anim = anime.createTimeline({ loop: true, autoplay: false }).add(a, back).add(a, toward);

      if (d.hover === 'hide') {
        var hide = function () { a.classList.add('bap-off'); };
        var show = function () { if (!s.removed) a.classList.remove('bap-off'); };
        el.addEventListener('pointerenter', hide);
        el.addEventListener('pointerleave', show);
        el.addEventListener('focusin', hide);
        el.addEventListener('focusout', show);
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._bap;
          if (!s.anim) return;
          if (e.isIntersecting && !s.removed) s.anim.play();
          else s.anim.pause();
        });
      }, { threshold: 0 });
      document.querySelectorAll('[data-bouncing-arrow-pointer]').forEach(function (el) {
        if (el._bap) return;
        setup(el);
        io.observe(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.arrowPointer = function (el, show) {
      var s = el._bap;
      if (!s) return;
      s.removed = show === false;
      s.arrow.classList.toggle('bap-off', s.removed);
      if (s.anim) { if (s.removed) s.anim.pause(); else s.anim.play(); }
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-bouncing-arrow-pointer>Claim your spot</button>

<!-- or a red arrow underneath, pointing up: -->
<button data-bouncing-arrow-pointer data-side="bottom" data-color="#dc2626">Claim your spot</button>