Wavy Underline
Hover a link and a squiggly underline draws in beneath it and keeps flowing sideways like a ripple on water. Move away and it slips off to the right. It can also start as a quiet straight line that turns wavy.
In running text, a quiet underline can turn wavy on hover.
How to use it
- Paste the snippet just before
</head>. - Add
data-wavy-underlineto 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-rest | Show a faint straight underline at rest, which turns wavy on hover. |
data-thickness | Line thickness, in px. Default scales with the text, at least 2. |
data-amplitude | Height of the waves, in px. Default scales with the text. |
data-wavelength | Length of one wave, in px. Default scales with the text. |
data-speed | How long one wave takes to flow past. Default 700 ms. |
data-offset | Move the line further down, in px. Default 0. |
data-autoplay | Links that share a parent take turns while the pointer isn’t on any. Handy for demos. |
It replaces the link’s normal underline. Keyboard focus shows it too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Wavy Underline: hover a link and a squiggly underline draws in beneath it
// and keeps flowing sideways like a ripple, until the pointer leaves and it
// slips away to the right.
// Usage: <a href="/blog" data-wavy-underline>Read the blog</a>
// Optional: data-color (line colour, default the link's own colour; two
// comma-separated colours make a gradient), data-thickness (px, default
// scales with the text, at least 2), data-amplitude (wave height, px,
// default scales with the text), data-wavelength (px, default scales with
// the text), data-speed (ms for one wave to flow past, default 700),
// data-offset (moves the line down, px, default 0), data-rest (show a
// straight underline at rest that turns wavy on hover), data-autoplay
// (links that share a parent take turns while the pointer isn't on any;
// handy for demos and touch screens).
var DEFAULTS = { speed: 700, offset: 0 };
var css =
'.wvu{position:relative;display:inline-block;text-decoration:none}' +
'.wvu-line,.wvu-rest{position:absolute;left:0;right:0;pointer-events:none}' +
'.wvu-line{-webkit-mask-repeat:repeat-x;mask-repeat:repeat-x;clip-path:inset(0 100% 0 0);' +
'animation:wvu-flow var(--wvu-t) linear infinite paused}' +
'.wvu-run .wvu-line{animation-play-state:running}' +
'.wvu-rest{opacity:.45;border-radius:9px;transition:opacity .25s ease,transform .35s ease}' +
'.wvu-on .wvu-rest{opacity:0;transform:scaleY(2)}' +
'@keyframes wvu-flow{from{-webkit-mask-position:0 0;mask-position:0 0}to{-webkit-mask-position:calc(var(--wvu-w) * -1) 0;mask-position:calc(var(--wvu-w) * -1) 0}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// One wavelength of the squiggle, as an SVG mask that tiles seamlessly.
function wave(w, a, t) {
var h = 2 * a + t, c = h / 2;
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '">' +
'<path d="M0 ' + c + ' Q' + w / 4 + ' ' + (c - 2 * a) + ' ' + w / 2 + ' ' + c + ' T' + w + ' ' + c + '" fill="none" stroke="#000" stroke-width="' + t + '"/></svg>';
return { url: 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '")', h: h };
}
function setup(el) {
var d = el.dataset;
var fs = parseFloat(getComputedStyle(el).fontSize);
var t = d.thickness !== undefined ? +d.thickness : Math.max(2, Math.round(fs * 0.06));
var a = d.amplitude !== undefined ? +d.amplitude : Math.max(2, Math.round(fs * 0.07));
var w = d.wavelength !== undefined ? +d.wavelength : Math.max(10, Math.round(fs * 0.42));
var offset = d.offset !== undefined ? +d.offset : DEFAULTS.offset;
var colors = (d.color || 'currentColor').split(',');
var paint = colors.length > 1 ? 'linear-gradient(90deg,' + colors.join(',') + ')' : colors[0];
el.classList.add('wvu');
el.style.setProperty('--wvu-w', w + 'px');
el.style.setProperty('--wvu-t', (d.speed !== undefined ? +d.speed : DEFAULTS.speed) + 'ms');
var m = wave(w, a, t);
var line = document.createElement('span');
line.className = 'wvu-line';
line.setAttribute('aria-hidden', 'true');
line.style.height = m.h + 'px';
line.style.top = 'calc(100% + ' + (offset - m.h / 2) + 'px)';
line.style.background = paint;
line.style.webkitMaskImage = line.style.maskImage = m.url;
line.style.webkitMaskSize = line.style.maskSize = w + 'px ' + m.h + 'px';
el.appendChild(line);
if (d.rest !== undefined && d.rest !== 'false') {
var rest = document.createElement('span');
rest.className = 'wvu-rest';
rest.setAttribute('aria-hidden', 'true');
rest.style.height = Math.max(1, t - 1) + 'px';
rest.style.top = 'calc(100% + ' + (offset - Math.max(1, t - 1) / 2) + 'px)';
rest.style.background = paint;
el.appendChild(rest);
}
// The wave covers 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.clipPath = 'inset(0 ' + ((1 - span.r) * 100).toFixed(2) + '% 0 ' + (span.l * 100).toFixed(2) + '%)';
}
function enter() {
if (on) return;
on = true;
if (anim) anim.pause();
if (span.l > 0) span.l = span.r = 0;
el.classList.add('wvu-on');
if (!reduced()) el.classList.add('wvu-run');
anim = anime.animate(span, { l: 0, r: 1, duration: reduced() ? 0 : 550, ease: 'out(3)', onUpdate: draw });
}
function leave() {
if (!on) return;
on = false;
if (anim) anim.pause();
el.classList.remove('wvu-on');
anim = anime.animate(span, {
l: 1,
r: 1,
duration: reduced() ? 0 : 500,
ease: 'inOut(3)',
onUpdate: draw,
onComplete: function () { span.l = span.r = 0; draw(); el.classList.remove('wvu-run'); }
});
}
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { el._wvuReal = true; enter(); } });
el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { el._wvuReal = false; leave(); } });
// Touch: a tap shows the wave for a moment.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
el._wvuHold = Date.now() + 4000;
enter();
setTimeout(leave, 1600);
});
el.addEventListener('focus', enter);
el.addEventListener('blur', leave);
return { el: el, enter: enter, leave: leave };
}
// Links that share a parent take turns.
function autoplay(group) {
var i = 0, timer = null, visible = false;
function busy() {
return group.some(function (g) {
return g.el._wvuReal || g.el === document.activeElement || (g.el._wvuHold && Date.now() < g.el._wvuHold);
});
}
function step() {
if (!visible) return;
if (!busy()) {
var cur = group[i++ % group.length];
cur.enter();
setTimeout(function () { if (!cur.el._wvuReal && cur.el !== document.activeElement) cur.leave(); }, 1500);
}
timer = setTimeout(step, 1900);
}
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() {
var groups = [];
document.querySelectorAll('[data-wavy-underline]').forEach(function (el) {
if (el._wvu) return;
el._wvu = true;
var link = setup(el);
var auto = el.dataset.autoplay;
if (auto === undefined || auto === 'false' || reduced()) 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="/blog" data-wavy-underline>Blog</a>
<a href="/shop" data-wavy-underline data-color="#a855f7,#ec4899">Shop</a>
</nav>
<p>Read <a href="/docs" data-wavy-underline data-rest>the docs</a>.</p>