← Text effects

Text on a Path

A ribbon of text glides along a curve, rising and dipping with it, and wraps round seamlessly so the line never runs out. Use the gentle wave it comes with, or draw your own path.

Glide along any curve •

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-text-on-path to an element containing your text, and give it a width. Ending the text with a separator such as • makes the loop read nicely.

Options

Add any of these attributes to the same element.

data-pathYour own curve, as the d of an SVG path drawn in a 600 × 200 box.
data-speedHow fast the text travels, in units of that box per second. Default 60.
data-directionleft (default) or right.
data-sizeFont size, in units of that box. Default 30.
data-guideShow the curve as a faint dotted line.

The text takes its font and colour from the element. It only moves while on screen; with reduced motion turned on it sits still on the curve.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Text on a Path: a ribbon of text glides along an SVG curve, rising and
    // dipping with it, and wraps round seamlessly so the line never runs out.
    // Usage: <div data-text-on-path>Copy • Paste • Ship •</div>
    // Optional: data-path (your own curve, as an SVG path "d" in a 600 × 200
    // box), data-speed (pixels per second along the curve, default 60),
    // data-direction ("left" (default) or "right"), data-size (font size in
    // that box, default 30), data-guide (show the curve as a dotted line).
    var DEFAULTS = {
      path: 'M10 130 C110 20 210 20 300 100 S490 180 590 70',
      speed: 60,
      size: 30
    };
    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0;

    var css =
      '.top{display:block;width:100%;overflow:visible}' +
      '.top text{fill:currentColor;font-weight:800;letter-spacing:-0.02em}' +
      '.top-guide{fill:none;stroke:currentColor;stroke-opacity:.25;stroke-width:2;stroke-dasharray:2 7;stroke-linecap:round}' +
      '.top-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function node(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }

    function build(el) {
      var d = el.dataset;
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.textContent = '';
      var sr = document.createElement('span');
      sr.className = 'top-sr';
      sr.textContent = text;
      el.appendChild(sr);

      var id = 'top-path-' + ++uid;
      var svg = node('svg', { class: 'top', viewBox: '0 0 600 200', 'aria-hidden': 'true' }, el);
      var path = node('path', { id: id, d: d.path || DEFAULTS.path }, node('defs', {}, svg));
      if (d.guide !== undefined && d.guide !== 'false') {
        node('use', { href: '#' + id, class: 'top-guide' }, svg);
      }
      var size = +d.size || DEFAULTS.size;
      var label = node('text', { 'font-size': size, dy: '0.35em' }, svg);
      var tp = node('textPath', { href: '#' + id }, label);
      var s = { el: el, path: path, label: label, tp: tp, unit: text + String.fromCharCode(160, 160), size: size, unitLen: 0, offset: 0 };
      fill(s);
      return s;
    }

    // Measure one copy of the text, then repeat it until the curve is covered
    // with a copy to spare: sliding by exactly one copy then looks seamless.
    function fill(s) {
      s.tp.textContent = s.unit;
      // While hidden (display: none) nothing measures, so make a rough guess.
      s.unitLen = s.label.getComputedTextLength() || s.unit.length * s.size * 0.55;
      var copies = Math.ceil(s.path.getTotalLength() / s.unitLen) + 1;
      s.tp.textContent = new Array(copies + 1).join(s.unit);
    }

    function start(s) {
      var d = s.el.dataset;
      fill(s);
      var right = d.direction === 'right';
      var from = right ? -s.unitLen : 0;
      var anim = anime.animate(s, {
        offset: [from, from + (right ? 1 : -1) * s.unitLen],
        duration: (s.unitLen / (+d.speed || DEFAULTS.speed)) * 1000,
        ease: 'linear',
        loop: true,
        onUpdate: function () { s.tp.setAttribute('startOffset', s.offset.toFixed(2)); }
      });
      return anim;
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[data-text-on-path]').forEach(function (el) {
        if (el._top) return;
        var s = (el._top = build(el));
        // Reduced motion: the text simply sits on the curve.
        if (reduced) return;
        var anim = null;
        // Glide only while visible, to save battery.
        new IntersectionObserver(function (entries) {
          if (!entries[0].isIntersecting) { if (anim) anim.pause(); return; }
          if (anim) anim.play();
          else anim = start(s);
        }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-text-on-path style="width: 600px">Copy • Paste • Ship •</div>

<!-- your own curve, a gentle arch: -->
<div data-text-on-path data-path="M20 170 Q300 -20 580 170" data-guide>Over the rainbow •</div>