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
- Paste the snippet just before
</head>. - Put the number in an element with
data-morphing-digits, or leave it empty and adddata-clockfor the time. Size it withfont-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-hold | How long each value stays. Default 1800 ms. |
data-clock | Show the visitor’s local time, morphing every second. data-clock="12" for a 12-hour clock. |
data-duration | How long each morph takes. Default 700 ms. |
data-stagger | Delay between neighbouring digits. Default 60 ms. |
data-weight | Line thickness. Default 8. |
data-loop | With 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.
<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><p style="font-size: 72px"><span data-morphing-digits>2026</span></p>
<!-- a live clock: -->
<span data-morphing-digits data-clock></span>