Arrow Nudge
Hover a button or link and its arrow shoots forward out of sight, then loops round to slide back in from the other side. It can point any way, including up and to the right for links that open elsewhere.
How to use it
- Paste the snippet just before
</head>. - Add
data-arrow-nudgeto a button or link. The script adds an arrow at the end. - To use your own icon or character instead, put it inside and mark it
data-arrow.
Options
Add any of these attributes to the same element.
data-direction | Which way the arrow points and flies: right (default), left, up, down or up-right. |
data-repeat | Keep looping while the pointer stays on it. |
data-duration | How long one loop takes. Default 650 ms. |
data-autoplay | Elements that share a parent take turns while the pointer isn’t on any. Handy for demos. |
The arrow added by the script is 1em across and takes the text colour. It’s hidden from screen readers, so an icon-only button still needs an aria-label.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Arrow Nudge: hover a button or link and its arrow shoots forward out of
// sight, then loops round to slide back in from the other side.
// Usage: <a href="/more" data-arrow-nudge>Read more</a>
// The script adds an arrow at the end, or uses the child marked data-arrow
// if there is one (an icon or a character like →).
// Optional: data-direction (which way the arrow points and flies: "right"
// (default), "left", "up", "down" or "up-right"), data-repeat (keep looping
// while hovered), data-duration (ms for one loop, default 650),
// data-autoplay (elements that share a parent take turns while the pointer
// isn't on any; handy for demos and touch screens).
var DEFAULTS = { duration: 650 };
var DIRS = {
right: [1, 0, 0], left: [-1, 0, 180], up: [0, -1, -90], down: [0, 1, 90], 'up-right': [1, -1, -45]
};
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 =
'.anu-clip{display:inline-flex;align-items:center;justify-content:center;overflow:hidden;vertical-align:middle;line-height:1;flex:none}' +
'.anu-arrow{display:inline-flex;align-items:center;justify-content:center}' +
'.anu-auto{width:1.05em;height:1.05em;margin-left:.4em}' +
'.anu-auto svg{display:block;width:100%;height:100%}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }
function setup(el) {
var d = el.dataset;
var dir = DIRS[d.direction] || DIRS.right;
var duration = num(d.duration, DEFAULTS.duration);
var repeat = d.repeat !== undefined && d.repeat !== 'false';
var arrow = el.querySelector('[data-arrow]');
var clip = document.createElement('span');
clip.className = 'anu-clip';
clip.setAttribute('aria-hidden', 'true');
if (arrow) {
arrow.parentNode.insertBefore(clip, arrow);
} else {
arrow = document.createElement('span');
arrow.innerHTML = ARROW;
arrow.firstChild.style.transform = 'rotate(' + dir[2] + 'deg)';
clip.classList.add('anu-auto');
el.appendChild(clip);
}
arrow.classList.add('anu-arrow');
clip.appendChild(arrow);
var busyLoop = false, on = false, real = false, hold = 0, again = null;
function loop() {
if (busyLoop) return;
busyLoop = true;
var w = clip.offsetWidth, h = clip.offsetHeight;
var dx = dir[0] * (w + 2), dy = dir[1] * (h + 2);
var t = duration;
anime.createTimeline({
onComplete: function () {
busyLoop = false;
if (repeat && on) again = setTimeout(loop, 250);
}
})
.add(arrow, { x: dx, y: dy, duration: t * 0.42, ease: 'in(3)' })
.add(arrow, { x: [-dx, 0], y: [-dy, 0], duration: t * 0.58, ease: 'out(4)' });
}
function enter() {
if (on) return;
on = true;
if (!reduced()) loop();
}
function leave() {
on = false;
clearTimeout(again);
}
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
real = true;
enter();
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
real = false;
leave();
});
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
hold = Date.now() + 3000;
on = false;
enter();
setTimeout(leave, duration);
});
el.addEventListener('focusin', enter);
el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) leave(); });
return {
enter: enter,
leave: leave,
busy: function () { return real || el.contains(document.activeElement) || Date.now() < hold; }
};
}
// Elements that share a parent take turns, one at a time.
function join(el, api) {
var host = el.parentNode;
var g = host._anuGroup;
if (g) { g.items.push(api); return; }
g = host._anuGroup = { items: [api], i: 0 };
var visible = false, timer = null, cur = null;
function tick() {
if (!visible) return;
if (g.items.some(function (a) { return a.busy(); })) {
if (cur && !cur.busy()) cur.leave();
cur = null;
timer = setTimeout(tick, 800);
return;
}
if (cur) cur.leave();
cur = g.items[g.i++ % g.items.length];
cur.enter();
timer = setTimeout(tick, g.items.length > 1 ? 1000 : 1500);
}
new IntersectionObserver(function (entries) {
var was = visible;
visible = entries[0].isIntersecting;
if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 400); }
}).observe(host);
}
function init() {
document.querySelectorAll('[data-arrow-nudge]').forEach(function (el) {
if (el._anu) return;
el._anu = true;
var api = setup(el);
var auto = el.dataset.autoplay;
if (auto === undefined || auto === 'false' || reduced()) return;
join(el, api);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><a href="/more" data-arrow-nudge>Read more</a>
<!-- an external link: -->
<a href="https://example.com" data-arrow-nudge data-direction="up-right">Open docs</a>
<!-- your own arrow: -->
<a href="/next" data-arrow-nudge>Next <span data-arrow>→</span></a>