Pricing Toggle Roll
A Monthly / Yearly switch for your pricing table. Flip it and every price rolls to its new value digit by digit, like a slot machine settling, while any other text that changes slides over too.
Starter
$9/mo
Billed monthly
Pro
$24/mo
Billed monthly
Team
$59/mo
Billed monthly
How to use it
- Paste the snippet just before
</head>. - Add
data-pricing-toggle-rollto the element around your pricing table. The script adds the switch at the top. - Give each price both values:
<span data-monthly="$24" data-yearly="$19">$24</span>. Any text with both attributes changes with the switch.
Options
Add any of these attributes to the same element.
data-labels | The two sides of the switch. Default Monthly|Yearly. |
data-badge | A note by the second side. Default Save 20%; an empty value hides it. |
data-yearly-first | Start on the yearly prices. |
data-autoplay | Flip itself every few seconds until someone uses the switch. |
data-duration | How long the digits roll. Default 900 ms. |
data-stagger | Delay between neighbouring characters. Default 60 ms. |
Prices that go down roll downwards and prices that go up roll upwards. Set the switch from a script with el._ptr.set(true) for yearly.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Pricing Toggle Roll: a Monthly / Yearly switch for a pricing table. Flip
// it and every price rolls to its new value digit by digit, like a slot
// machine settling, while any other changing text slides over too.
// Usage:
// <div data-pricing-toggle-roll>
// <p><span data-monthly="$24" data-yearly="$19">$24</span> /month</p>
// </div>
// The script adds the switch at the top. Anything inside with both
// data-monthly and data-yearly changes with it.
// Optional: data-labels (the two sides, default "Monthly|Yearly"),
// data-badge (a note by the second side, default "Save 20%"; "" hides it),
// data-yearly-first (start on the second side), data-autoplay (it flips
// itself every few seconds until someone uses it), data-duration (roll
// time, default 900 ms), data-stagger (delay between characters, default 60 ms).
// Script use: el._ptr.set(true) (true = yearly)
var DEFAULTS = { labels: 'Monthly|Yearly', badge: 'Save 20%', duration: 900, stagger: 60, interval: 2800 };
var css =
'.ptr-toggle{display:inline-flex;align-items:center;gap:0.7em;font-weight:600;font-size:0.95em;user-select:none;-webkit-user-select:none}' +
'.ptr-side{cursor:pointer;opacity:.5;transition:opacity .25s;white-space:nowrap}' +
'.ptr-side.ptr-on{opacity:1}' +
'.ptr-badge{display:inline-block;margin-left:0.45em;padding:0.18em 0.55em;border-radius:999px;font-size:0.78em;font-weight:700;' +
'background:#dcfce7;color:#15803d;vertical-align:0.08em}' +
'.ptr-switch{position:relative;flex:none;width:3.1em;height:1.75em;border-radius:999px;border:0;padding:0;cursor:pointer;' +
'background:rgba(15,15,30,.18);transition:background-color .3s}' +
'.ptr-switch[aria-checked="true"]{background:var(--ptr-color,#6a0dad)}' +
'.ptr-knob{position:absolute;top:0.2em;left:0.2em;width:1.35em;height:1.35em;border-radius:50%;background:#fff;' +
'box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .35s cubic-bezier(.3,1.5,.5,1)}' +
'.ptr-switch[aria-checked="true"] .ptr-knob{transform:translateX(1.35em)}' +
'.ptr-roll{display:inline-flex;white-space:pre;vertical-align:bottom}' +
'.ptr-slot{display:inline-block;height:1.2em;line-height:1.2em;overflow:hidden;position:relative}' +
'.ptr-strip{display:block}' +
'.ptr-strip>span{display:block;height:1.2em}' +
'.ptr-rolling{-webkit-mask-image:linear-gradient(transparent,#000 25%,#000 75%,transparent);mask-image:linear-gradient(transparent,#000 25%,#000 75%,transparent)}' +
'.ptr-measure{position:absolute;visibility:hidden;white-space:pre;left:0;top:0}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function isDigit(c) { return c >= '0' && c <= '9'; }
function make(tag, cls, parent, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined) n.textContent = text;
parent.appendChild(n);
return n;
}
// Line two strings up: shared start, shared end, and the middle
// right-aligned (so numbers keep their units and tens in place).
// Each entry is one slot: a single character, or a whole changed word.
function align(a, b) {
var i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) i++;
var j = 0;
while (j < a.length - i && j < b.length - i && a[a.length - 1 - j] === b[b.length - 1 - j]) j++;
var sa = a.slice(i, a.length - j), sb = b.slice(i, b.length - j);
// Words change as one piece; only numbers roll digit by digit.
var num = /^[\d.,\s]*$/;
var ma = num.test(sa) && num.test(sb) ? sa.split('') : [sa], mb = num.test(sa) && num.test(sb) ? sb.split('') : [sb];
while (ma.length < mb.length) ma.unshift('');
while (mb.length < ma.length) mb.unshift('');
var head = a.slice(0, i).split(''), tail = a.slice(a.length - j).split('');
return { a: head.concat(ma, tail), b: head.concat(mb, tail) };
}
// The characters a wheel passes through from x to y: the digits in between
// (counting up or down, like an odometer), or just x then y.
function sequence(x, y, up) {
if (isDigit(x) && isDigit(y)) {
var out = [x], n = +x;
while (String(n) !== y) { n = (n + (up ? 1 : 9)) % 10; out.push(String(n)); }
return out;
}
return [x, y];
}
function width(el, ch) {
if (!ch) return 0;
var m = el._ptrMeasure;
if (!m) m = el._ptrMeasure = make('span', 'ptr-measure', el);
m.textContent = ch;
return m.getBoundingClientRect().width;
}
function staticText(el, text) {
el.textContent = '';
el._ptrMeasure = null;
var row = make('span', 'ptr-roll', el);
row.setAttribute('aria-hidden', 'true');
text.split('').forEach(function (ch) { make('span', 'ptr-strip', make('span', 'ptr-slot', row), ch); });
el.setAttribute('aria-label', text);
el._ptrText = text;
}
// Roll one element from its current text to the next.
function roll(el, next, opts) {
if (el._ptrAnims) el._ptrAnims.forEach(function (a) { a.pause(); });
var prev = el._ptrText;
if (prev === next) return;
if (opts.still) { staticText(el, next); return; }
var pa = parseFloat(prev.replace(/[^\d.]/g, '')), pb = parseFloat(next.replace(/[^\d.]/g, ''));
var up = !(pb < pa);
var al = align(prev, next);
var row = el.querySelector('.ptr-roll');
row.textContent = '';
el._ptrText = next;
el.setAttribute('aria-label', next);
el._ptrAnims = [];
var changed = [];
al.a.forEach(function (x, i) {
var y = al.b[i];
var slot = make('span', 'ptr-slot', row);
var strip = make('span', 'ptr-strip', slot);
if (x === y) { strip.textContent = x; return; }
var seq = sequence(x, y, up);
if (!up) seq.reverse();
seq.forEach(function (ch) { make('span', '', strip, ch); });
slot.classList.add('ptr-rolling');
changed.push({ slot: slot, strip: strip, n: seq.length, x: x, y: y });
});
var count = changed.length;
changed.forEach(function (c, k) {
var from = up ? 0 : -(c.n - 1) * 1.2, to = up ? -(c.n - 1) * 1.2 : 0;
var dur = opts.duration + Math.min(c.n, 6) * 40;
var delay = k * opts.stagger;
anime.utils.set(c.strip, { y: from + 'em' });
el._ptrAnims.push(anime.animate(c.strip, { y: to + 'em', duration: dur, delay: delay, ease: 'outBack(1.2)' }));
el._ptrAnims.push(anime.animate(c.slot, { width: [width(el, c.x), width(el, c.y)], duration: dur * 0.8, delay: delay, ease: 'out(3)',
onComplete: function () {
if (--count === 0 && el._ptrText === next) staticText(el, next);
}
}));
});
}
function setup(root) {
var d = root.dataset;
var fake = !!root.closest('[aria-hidden="true"]'); // inside a card thumb: no real controls
var labels = (d.labels || DEFAULTS.labels).split('|');
var badge = d.badge !== undefined ? d.badge : DEFAULTS.badge;
var p = {
root: root, yearly: d.yearlyFirst !== undefined, still: reduced(),
duration: parseFloat(d.duration) || DEFAULTS.duration,
stagger: d.stagger !== undefined ? parseFloat(d.stagger) : DEFAULTS.stagger,
items: Array.prototype.slice.call(root.querySelectorAll('[data-monthly][data-yearly]'))
};
var bar = document.createElement('div');
bar.className = 'ptr-toggle';
var left = make('span', 'ptr-side', bar, labels[0]);
var sw = make(fake ? 'span' : 'button', 'ptr-switch', bar);
make('span', 'ptr-knob', sw);
var right = make('span', 'ptr-side', bar, labels[1] || 'Yearly');
if (badge) make('span', 'ptr-badge', right, badge);
sw.setAttribute('role', 'switch');
if (!fake) {
sw.type = 'button';
sw.setAttribute('aria-label', 'Billed ' + (labels[1] || 'yearly').toLowerCase());
}
root.insertBefore(bar, root.firstChild);
p.sw = sw; p.left = left; p.right = right;
function user(v) { return function (e) { if (e.isTrusted) p.user = true; set(p, v === null ? !p.yearly : v); }; }
if (!fake) {
sw.addEventListener('click', user(null));
left.addEventListener('click', user(false));
right.addEventListener('click', user(true));
}
p.items.forEach(function (el) { staticText(el, el.getAttribute(p.yearly ? 'data-yearly' : 'data-monthly')); });
p.set = function (v) { set(p, v); };
mark(p);
return p;
}
function mark(p) {
p.sw.setAttribute('aria-checked', p.yearly ? 'true' : 'false');
p.left.classList.toggle('ptr-on', !p.yearly);
p.right.classList.toggle('ptr-on', p.yearly);
}
function set(p, yearly) {
if (yearly === p.yearly) return;
p.yearly = yearly;
mark(p);
p.items.forEach(function (el, i) {
var next = el.getAttribute(yearly ? 'data-yearly' : 'data-monthly');
setTimeout(function () { roll(el, next, p); }, p.still ? 0 : i * 70);
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var p = e.target._ptr;
p.visible = e.isIntersecting;
if (!p.visible || p.ticking) return;
p.ticking = true;
(function tick() {
setTimeout(function () {
if (p.user) return;
if (p.visible) set(p, !p.yearly);
tick();
}, DEFAULTS.interval);
})();
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-pricing-toggle-roll]').forEach(function (root) {
if (root._ptr) return;
var p = root._ptr = setup(root);
if (root.dataset.autoplay !== undefined && root.dataset.autoplay !== 'false') io.observe(root);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-pricing-toggle-roll>
<div class="plan">
<h3>Pro</h3>
<p><span data-monthly="$24" data-yearly="$19">$24</span> /month</p>
<p data-monthly="Billed monthly" data-yearly="Billed yearly">Billed monthly</p>
</div>
</div>