Chevron Flow
A row of chevrons light up one after another, so a wave of light runs the way they point. Use it as a “this way” sign, at the end of a button, or as a “scroll down” hint.
Scroll
How to use it
- Paste the snippet just before
</head>. - Add
data-chevron-flowto an empty<span>, on its own or inside a button or link. The chevrons are sized by its font size.
Options
Add any of these attributes to the same element.
data-count | Number of chevrons. Default 3. |
data-direction | Which way they point: right, left, up or down. Default right. |
data-color | Chevron colour. Default the text colour. |
data-color2 | Shade the chevrons from data-color to this colour. |
data-step | Time between one chevron lighting and the next. Default 160 ms. |
data-rest | Pause between waves, in steps. Default 2. |
data-dim | Opacity of an unlit chevron. Default 0.22. |
data-hover | Only flow while the button or link it’s in is pointed at, focused or tapped. |
The chevrons are hidden from screen readers, so say where it leads in the text or an aria-label. It pauses while off screen, and with reduced motion they simply show, all lit.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Chevron Flow: a row of chevrons light up one after another, so a wave of
// light runs the way they point. Use it on its own as a "this way" sign, or
// inside a button or a "scroll down" hint.
// Usage: <span data-chevron-flow></span>
// <button>Next <span data-chevron-flow data-count="3"></span></button>
// Optional: data-count (chevrons, default 3), data-direction (right, left,
// up or down, default right), data-color (default the text colour),
// data-color2 (shade the chevrons from data-color to this one),
// data-step (ms between one chevron and the next, default 160),
// data-rest (pause between waves, in steps, default 2), data-dim (opacity
// of an unlit chevron, default 0.22), data-hover (only flow while pointed
// at or tapped).
var DEFAULTS = { count: 3, step: 160, rest: 2, dim: 0.22 };
var ROT = { right: 0, down: 90, left: 180, up: -90 };
var css = '.chvf-row{display:inline-flex;align-items:center;vertical-align:middle;line-height:0}' +
'.chvf-col{flex-direction:column}' +
'.chvf-row svg{width:.62em;height:1em;flex:none;overflow:visible}' +
'.chvf-row:not(.chvf-col) svg+svg{margin-left:-.12em}' +
'.chvf-col svg{width:1em;height:.62em}' +
'.chvf-col svg+svg{margin-top:-.12em}';
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, f) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : f; }
function setup(el) {
var d = el.dataset;
var count = Math.max(1, Math.round(num(d.count, DEFAULTS.count)));
var dir = ROT[d.direction] !== undefined ? d.direction : 'right';
var vertical = dir === 'up' || dir === 'down';
var row = document.createElement('span');
row.className = 'chvf-row' + (vertical ? ' chvf-col' : '');
row.setAttribute('aria-hidden', 'true');
var chevs = [];
for (var i = 0; i < count; i++) {
// Chevrons are drawn pointing right, then turned to face the direction.
var vb = vertical ? '0 0 20 12' : '0 0 12 20';
var pts = vertical ? '2,2 10,10 18,2' : '2,2 10,10 2,18';
if (dir === 'up') pts = '2,10 10,2 18,10';
if (dir === 'left') pts = '10,2 2,10 10,18';
row.insertAdjacentHTML('beforeend', '<svg viewBox="' + vb + '" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="' + pts + '"/></svg>');
chevs.push(row.lastChild);
}
// Light the first chevron in the direction they point first.
if (dir === 'left' || dir === 'up') chevs.reverse();
if (d.color || d.color2) {
var c1 = d.color || 'currentColor', c2 = d.color2 || c1;
chevs.forEach(function (c, i) {
var k = count > 1 ? i / (count - 1) * 100 : 0;
c.style.color = c1 === c2 ? c1 : 'color-mix(in srgb, ' + c2 + ' ' + k.toFixed(0) + '%, ' + c1 + ')';
});
}
el.appendChild(row);
var unit = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }[dir];
return { el: el, chevs: chevs, unit: unit };
}
function light(o, t, dim, step, rest) {
var n = o.chevs.length, period = n + rest + 1;
o.chevs.forEach(function (c, i) {
// A soft bump of light two steps wide passes over each chevron in turn.
var u = ((t / step - i) % period + period) % period;
var k = u < 2 ? Math.sin(Math.PI * u / 2) : 0;
k = k * k * (3 - 2 * k);
c.style.opacity = (dim + (1 - dim) * k).toFixed(3);
var nudge = (k * 0.12).toFixed(3);
c.style.transform = 'translate(' + nudge * o.unit[0] + 'em,' + nudge * o.unit[1] + 'em)';
});
}
function run(o) {
var d = o.el.dataset;
var step = num(d.step, DEFAULTS.step), rest = num(d.rest, DEFAULTS.rest), dim = num(d.dim, DEFAULTS.dim);
var hoverOnly = d.hover !== undefined && d.hover !== 'false';
var on = !hoverOnly, visible = false, raf = 0, last = 0, clock = 0, tapEnd = 0;
var period = (o.chevs.length + rest + 1) * step;
function frame(now) {
raf = 0;
if (!visible) return;
if (tapEnd && now > tapEnd) { tapEnd = 0; on = false; }
var dt = Math.min(50, now - (last || now));
last = now;
// When switched off, run on to the end of the current wave, then rest.
if (on || clock % period > 1e-3) {
var before = clock % period;
clock += dt;
if (!on && clock % period < before) clock = Math.ceil(clock / period - 1) * period;
}
light(o, clock, dim, step, rest);
if (on || clock % period > 1e-3) raf = requestAnimationFrame(frame);
}
function wake() { last = 0; if (visible && !raf) raf = requestAnimationFrame(frame); }
light(o, hoverOnly ? 0 : step * 1.2, dim, step, rest);
if (hoverOnly) {
var host = o.el.closest('a, button') || o.el;
host.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { on = true; tapEnd = 0; wake(); } });
host.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') on = false; });
host.addEventListener('focusin', function () { on = true; wake(); });
host.addEventListener('focusout', function () { on = false; });
host.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse') return;
on = true;
tapEnd = performance.now() + period * 2;
wake();
});
}
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
wake();
}).observe(o.el);
}
function init() {
var still = reduced();
document.querySelectorAll('[data-chevron-flow]').forEach(function (el) {
if (el._chvf) return;
el._chvf = true;
var o = setup(el);
if (!still) run(o);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><a href="/start">Get started <span data-chevron-flow></span></a>
<!-- a scroll hint: -->
<span data-chevron-flow data-direction="down" data-count="2"></span>