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.
How to use it
- Paste the snippet just before
</head>. - Add
data-link-underline-slideto any link.
Options
Add any of these attributes to the same element.
data-color | Line colour. Default the link’s own colour. Two comma-separated colours make a gradient. |
data-thickness | Line thickness, in px. Default 2, or thicker on big text. |
data-offset | Gap between the text and the line, in px. Default 2. |
data-autoplay | Links 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.
<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><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>