← Numbers

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

  1. Paste the snippet just before </head>.
  2. Add data-pricing-toggle-roll to the element around your pricing table. The script adds the switch at the top.
  3. 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-labelsThe two sides of the switch. Default Monthly|Yearly.
data-badgeA note by the second side. Default Save 20%; an empty value hides it.
data-yearly-firstStart on the yearly prices.
data-autoplayFlip itself every few seconds until someone uses the switch.
data-durationHow long the digits roll. Default 900 ms.
data-staggerDelay 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.

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