← Numbers

Morphing Digits

Every digit is drawn as one smooth line, and when the number changes each line bends and flows into the shape of the next digit. On the way in the digits unfurl from a dot. Cycle through values or run it as a live clock.

0.25

How to use it

  1. Paste the snippet just before </head>.
  2. Put the number in an element with data-morphing-digits, or leave it empty and add data-clock for the time. Size it with font-size; the lines take the text colour.

Options

Add any of these attributes to the same element.

data-values="A|B|C"Several values to morph between in turn.
data-holdHow long each value stays. Default 1800 ms.
data-clockShow the visitor’s local time, morphing every second. data-clock="12" for a 12-hour clock.
data-durationHow long each morph takes. Default 700 ms.
data-staggerDelay between neighbouring digits. Default 60 ms.
data-weightLine thickness. Default 8.
data-loopWith a single value: fold away to dots and unfurl again every few seconds.

Only the digits that change move. Colons, dots and commas stay put; other characters show as plain text.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Morphing Digits: every digit is a single drawn line, and when the value
    // changes each line bends and flows into the shape of the next number. On
    // the way in the digits unfurl from a dot.
    // Usage: <span data-morphing-digits>2026</span>
    //    or: <span data-morphing-digits data-clock></span>   (live local time)
    // Optional: data-values (several values to morph between, "A|B|C"),
    // data-hold (ms each value stays, default 1800), data-clock (show the time,
    // ticking every second; data-clock="12" for a 12-hour clock),
    // data-duration (ms per morph, default 700), data-stagger (delay between
    // neighbouring digits, default 60), data-weight (line thickness, default 8),
    // data-loop (with a single value: fold away and unfurl again).
    // Lines take the text colour; size it with font-size.
    var DEFAULTS = { hold: 1800, duration: 700, stagger: 60, weight: 8 };
    var N = 120; // points per digit outline; every digit uses the same count so they can morph

    var css =
      '.mph{display:inline-flex;align-items:center;gap:.04em;line-height:1;white-space:nowrap;vertical-align:middle}' +
      '.mph svg{display:block;height:1em;overflow:visible}' +
      '.mph-d{width:.6em}.mph-s{width:.26em}' +
      '.mph-d path{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round}' +
      '.mph-s circle{fill:currentColor}' +
      '.mph-c{display:block;min-width:.3em;text-align:center}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // ---- Digit outlines in a 60 × 100 box, built from arcs and lines ----
    function arc(cx, cy, rx, ry, a0, a1) {
      var pts = [], steps = Math.max(8, Math.round(Math.abs(a1 - a0) / 3));
      for (var i = 0; i <= steps; i++) {
        var a = (a0 + (a1 - a0) * i / steps) * Math.PI / 180;
        pts.push([cx + rx * Math.cos(a), cy + ry * Math.sin(a)]);
      }
      return pts;
    }
    function join() {
      var out = [];
      for (var i = 0; i < arguments.length; i++) out = out.concat(arguments[i]);
      return out;
    }
    // Spread N points evenly along the line, so point i of one digit maps onto point i of the next.
    function resample(pts) {
      var len = [0];
      for (var i = 1; i < pts.length; i++) {
        len.push(len[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
      }
      var total = len[len.length - 1], out = [], j = 1;
      for (var k = 0; k < N; k++) {
        var at = total * k / (N - 1);
        while (j < len.length - 1 && len[j] < at) j++;
        var seg = len[j] - len[j - 1] || 1, t = Math.min(1, Math.max(0, (at - len[j - 1]) / seg));
        out.push([pts[j - 1][0] + (pts[j][0] - pts[j - 1][0]) * t, pts[j - 1][1] + (pts[j][1] - pts[j - 1][1]) * t]);
      }
      return out;
    }
    var six = join(arc(54, 72, 47, 66, -100, -180), arc(30, 72, 23, 23, 180, -180));
    var RAW = {
      0: arc(30, 50, 25, 45, -90, 270),
      1: [[17, 22], [35, 6], [35, 94]],
      2: join(arc(30, 29, 23, 23, -170, 35), [[7, 94], [55, 94]]),
      3: join(arc(30, 27, 21, 21, -160, 90), arc(30, 71, 23, 23, -90, 160)),
      4: [[42, 94], [42, 6], [6, 68], [56, 68]],
      5: join([[52, 6], [15, 6]], arc(30, 65, 27, 27, -134, 150)),
      6: six,
      7: [[6, 6], [54, 6], [22, 94]],
      8: join(arc(30, 27, 20, 20, 90, 450), arc(30, 72, 25, 25, -90, -450)),
      9: six.map(function (p) { return [60 - p[0], 100 - p[1]]; })
    };
    var SHAPES = {};
    for (var dgt in RAW) SHAPES[dgt] = resample(RAW[dgt]);
    var DOT = [];
    for (var q = 0; q < N; q++) DOT.push([30, 50]);

    function pathOf(pts) {
      var s = 'M' + pts[0][0].toFixed(1) + ' ' + pts[0][1].toFixed(1);
      for (var i = 1; i < pts.length; i++) s += 'L' + pts[i][0].toFixed(1) + ' ' + pts[i][1].toFixed(1);
      return s;
    }

    var NS = 'http://www.w3.org/2000/svg';
    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function clockText(mode) {
      var d = new Date(), h = d.getHours();
      if (mode === '12') h = h % 12 || 12;
      var p = function (n) { return (n < 10 ? '0' : '') + n; };
      return p(h) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
    }

    // A "pattern" is the value with every digit replaced by #: values with the
    // same pattern morph digit by digit, anything else is rebuilt.
    function pattern(text) { return text.replace(/\d/g, '#'); }

    function build(m, text) {
      m.el.innerHTML = '';
      m.slots = [];
      m.pattern = pattern(text);
      for (var i = 0; i < text.length; i++) {
        var ch = text.charAt(i), node;
        if (/\d/.test(ch)) {
          node = document.createElementNS(NS, 'svg');
          node.setAttribute('class', 'mph-d');
          node.setAttribute('viewBox', '0 0 60 100');
          var path = document.createElementNS(NS, 'path');
          path.setAttribute('stroke-width', m.weight);
          node.appendChild(path);
          m.slots.push({ path: path, cur: DOT, digit: null, anim: null });
        } else if (ch === ':' || ch === '.' || ch === ',') {
          node = document.createElementNS(NS, 'svg');
          node.setAttribute('class', 'mph-s');
          node.setAttribute('viewBox', '0 0 26 100');
          var r = m.weight * 0.75;
          var ys = ch === ':' ? [32, 72] : [94];
          ys.forEach(function (y) {
            var c = document.createElementNS(NS, 'circle');
            c.setAttribute('cx', 13); c.setAttribute('cy', y); c.setAttribute('r', r);
            node.appendChild(c);
          });
        } else {
          node = document.createElement('span');
          node.className = 'mph-c';
          node.textContent = ch === ' ' ? ' ' : ch;
        }
        node.setAttribute('aria-hidden', 'true');
        m.el.appendChild(node);
      }
    }

    function morph(m, slot, to, delay) {
      if (slot.anim) slot.anim.pause();
      var from = slot.cur, state = { t: 0 };
      if (m.still) { slot.cur = to; slot.path.setAttribute('d', pathOf(to)); return; }
      slot.anim = anime.animate(state, {
        t: 1, duration: m.duration, delay: delay, ease: 'inOut(3)',
        onUpdate: function () {
          var t = state.t, pts = [];
          for (var i = 0; i < N; i++) {
            pts.push([from[i][0] + (to[i][0] - from[i][0]) * t, from[i][1] + (to[i][1] - from[i][1]) * t]);
          }
          slot.cur = pts;
          slot.path.setAttribute('d', pathOf(pts));
        }
      });
    }

    // Show a value: morph only the digits that change, left to right.
    function show(m, text) {
      m.el.setAttribute('aria-label', text);
      if (pattern(text) !== m.pattern) build(m, text);
      var digits = text.replace(/\D/g, ''), n = 0;
      m.slots.forEach(function (slot, i) {
        var dg = digits.charAt(i);
        if (slot.digit === dg) return;
        slot.digit = dg;
        morph(m, slot, SHAPES[dg], n++ * m.stagger);
      });
    }

    function fold(m) {
      m.slots.forEach(function (slot, i) { slot.digit = null; morph(m, slot, DOT, i * m.stagger); });
    }

    function setup(el) {
      var d = el.dataset;
      var values = d.values ? d.values.split('|') : [el.textContent.trim() || '0'];
      var m = {
        el: el, values: values, index: 0, pattern: null, slots: [],
        clock: d.clock !== undefined ? d.clock : null,
        hold: +d.hold || DEFAULTS.hold, duration: +d.duration || DEFAULTS.duration,
        stagger: d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger,
        weight: +d.weight || DEFAULTS.weight,
        loop: d.loop !== undefined && d.loop !== 'false', still: reduced(), visible: false, timer: 0, started: false
      };
      el.classList.add('mph');
      el.setAttribute('role', 'img');
      var first = m.clock !== null ? clockText(m.clock) : values[0];
      build(m, first);
      el.setAttribute('aria-label', first);
      m.slots.forEach(function (s) { s.path.setAttribute('d', pathOf(DOT)); });
      if (m.still) show(m, first);
      return m;
    }

    // Runs while in view: the clock ticks every second, values step every hold.
    function step(m) {
      clearTimeout(m.timer);
      if (!m.visible) return;
      if (m.clock !== null) {
        show(m, clockText(m.clock));
        m.timer = setTimeout(function () { step(m); }, 1000 - (Date.now() % 1000) + 20);
        return;
      }
      if (m.values.length > 1) {
        show(m, m.values[m.index]);
        m.index = (m.index + 1) % m.values.length;
        m.timer = setTimeout(function () { step(m); }, m.hold + m.duration);
        return;
      }
      show(m, m.values[0]);
      if (!m.loop) return;
      m.timer = setTimeout(function () {
        fold(m);
        m.timer = setTimeout(function () { step(m); }, m.duration + m.slots.length * m.stagger + 300);
      }, m.hold + m.duration);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var m = e.target._mph;
          m.visible = e.isIntersecting;
          if (m.visible) {
            if (!m.started || m.clock !== null || m.values.length > 1 || m.loop) step(m);
            m.started = true;
          } else clearTimeout(m.timer);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-morphing-digits]').forEach(function (el) {
        if (el._mph) return;
        var m = (el._mph = setup(el));
        if (m.still) {
          if (m.clock !== null) setInterval(function () { show(m, clockText(m.clock)); }, 1000);
          return;
        }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p style="font-size: 72px"><span data-morphing-digits>2026</span></p>

<!-- a live clock: -->
<span data-morphing-digits data-clock></span>