← SVG & icons

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

  1. Paste the snippet just before </head>.
  2. Add data-chevron-flow to 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-countNumber of chevrons. Default 3.
data-directionWhich way they point: right, left, up or down. Default right.
data-colorChevron colour. Default the text colour.
data-color2Shade the chevrons from data-color to this colour.
data-stepTime between one chevron lighting and the next. Default 160 ms.
data-restPause between waves, in steps. Default 2.
data-dimOpacity of an unlit chevron. Default 0.22.
data-hoverOnly 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.

1 · Snippet
<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>
2 · Your HTML
<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>