Forms✨ Premium

Pricing Toggle Odometer

Monthly / yearly switch for a pricing card: each digit rolls in its own column like a counter, and the savings badge and yearly amount appear.

Vanilla JSOdometerPricing

Updated

This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Forms has 20 effects, including 2 free. Explore the category →

3 usage examples

Pricing page of a reading club — two plans, one switch per card — Pricing Toggle Odometer example 1

① Pricing page of a reading club — two plans, one switch per card

WhenThe visitor compares the Reader and Circle plans. They switch Circle to yearly: 19 rolls to 15, "−21%" appears and "Billed $180 / year" shows up, while Reader stays monthly until its own switch is touched.
WhyOne switch per card lets people compare one plan monthly with the other yearly. The rolling draws the eye to the digit that changes, without leaving the page or reloading the grid.
SettingsTwo cards in a single root (display:flex, --pto-w:auto so they share the width). Reader: 9 / 7; Circle: 19 / 15. Rail and button colours follow the club's terracotta palette. In the capture, clicks outside the switches are relayed to the Circle switch, then to the Reader switch.
"Switch to yearly billing" banner in a customer account — Pricing Toggle Odometer example 2

② "Switch to yearly billing" banner in a customer account

WhenA subscriber opens their notes space. A banner at the top offers yearly billing: they flip the switch and immediately see their price go from 8 to 6 a month, with "−25%" and the amount billed each year.
WhyThe customer decides on a number, not a promise: they see what they would pay before clicking anything binding. The banner stays one row high and the account keeps its list of notes in view.
SettingsThe [data-pto] container is not a .pto-card: a two-row grid (text and switch on the left, price on the right). Price at 34 px (font-size on .pto-amount), 8 / 6.
Offer banner for a coffee subscription — the price flips on its own — Pricing Toggle Odometer example 3

③ Offer banner for a coffee subscription — the price flips on its own

WhenAt the top of a limited-offer page, a giant price alternates between 24 a month and 18 a year, with the "−25%" badge, every 1.6 s; the visitor can also take over with the switch at the bottom of the banner.
WhyThe repeated roll gets both prices read without asking for any action: the gap becomes the banner's hook, next to the offer's countdown.
SettingsPrice enlarged to 112 px (the columns are in em, so they follow). The scene ticks and unticks the checkbox every 1.6 s and fires the change event the code listens to: the sold code never flips by itself.

How it works

Each [data-pto] card inside .fx-0808-root carries its two whole-number prices: data-monthly="19" and data-yearly="15". On load, the script clears the plain price written in [data-pto-digits] (the one people read without JavaScript) and replaces it with one column per digit, each holding the digits 0 to 9 stacked. Showing "15" means shifting the first column up by 1 em and the second by 5 em: the transform .65s cubic-bezier(.2,.85,.25,1) transition makes the digits roll.

The switch is a real, invisible checkbox placed in a <label> with the .pto-track rail. On the change event, the script picks the yearly or monthly price, puts "Monthly" or "Annual" in bold to match, and reveals the .pto-save badge and the .pto-sub line ("Billed $180 / year", the yearly price × 12, computed on load). The badge shows data-save when provided, otherwise the real rounded saving: 19 → 15 gives −21%.

Every card owns its switch and its columns: three cards side by side or two copies of the block work without interfering, and pasting the script twice does not rebuild the columns. When one price has fewer digits than the other (9 vs 12), the leading zero is hidden. Inside a <form>, a reset unticks the checkbox and brings the monthly price straight back. Whole numbers only: parseInt drops cents.

Accessibility

  • Price read as words: the 0-9 columns are hidden from screen readers (aria-hidden); an invisible sentence, "$19 per month", updated on every switch, is announced through aria-live="polite".
  • Keyboard: the checkbox stays reachable with Tab and toggles with Space; a light ring is drawn around the rail on keyboard focus (:focus-visible). Its accessible name is "Annual billing".
  • Reduced motion: under prefers-reduced-motion: reduce, the digits are set straight to their value with no rolling, and the rail and badge change without transitions. The price still changes.
  • Without JavaScript: the monthly price written in the HTML stays on screen; the switch can be ticked but changes nothing.

Browser compatibility

Dependency-free JavaScript (padStart, matchMedia, classList) and everyday CSS. Measured in Chromium 151, Firefox 153 and WebKit 26.5 (Playwright): two copies on one page, a click on the second leaving the first untouched, Tab then Space, touch at 390 px, reduced motion, 0 errors. The focus ring relies on :focus-visible (Chrome 86, Firefox 85, Safari 15.4); earlier, the switch works from the keyboard with no visible ring.

Chrome 86+✓ Full (measured)
Firefox 85+✓ Full (measured)
Safari 15.4+✓ Full (measured in WebKit)
Edge 86+✓ Full (Chromium engine)
Mobile iOS 15.4+✓ WebKit engine, not checked on a device
Android Chrome 86+✓ Full (touch measured at 390 px)

Without JavaScript, the monthly price from the HTML stays readable. Without :focus-visible (Safari before 15.4), there is no ring on keyboard focus but the switch still works.

The code

HTML structure to paste into your page (the CSS + JS come with your purchase):

index.html — structure
<div class="fx-0808-root">
  <div class="pto-scene">
    <div class="pto-card" data-pto data-monthly="19" data-yearly="15">
      <label class="pto-switch">
        <span class="pto-m pto-on">Monthly</span>
        <input type="checkbox" data-pto-toggle aria-label="Annual billing">
        <span class="pto-track" aria-hidden="true"></span>
        <span class="pto-y">Annual</span>
      </label>
      <div class="pto-price">
        <span class="pto-amount" aria-hidden="true"><span class="pto-cur">$</span><span data-pto-digits>19</span></span>
        <span class="pto-suffix" aria-hidden="true">/ month</span>
        <span class="pto-save" data-pto-save>−<span data-pto-pct>21</span>%</span>
        <span class="pto-vh" aria-live="polite" aria-atomic="true">$<span data-pto-sr>19</span> per month</span>
      </div>
      <div class="pto-sub" data-pto-sub>Billed $<span data-pto-total>180</span> / year</div>
      <button type="button" class="pto-cta">Get started</button>
    </div>
  </div>
</div>

One-time payment, no subscription — the code is yours forever

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Prices (HTML — data-monthly and data-yearly on the card) 19 and 15 Whole numbers. Also write the monthly price in [data-pto-digits]: it is what shows without JavaScript.
Badge saving (HTML — data-save on the card) absent: computed (−21% for 19 → 15) Give a number to force the percentage shown, for example data-save="20".
Card width (CSS — --pto-w) 280px auto lets the card take its column's width (plan grid).
Price size (CSS — font-size on .pto-amount) 52px The digit columns are in em: they follow the size, from 30 px in a banner to 120 px in a hero.
Roll speed (CSS — transition on .pto-strip) .65s cubic-bezier(.2,.85,.25,1) Shorten to .4s for a snappier counter.
Switch colour (CSS — .pto-switch input:checked+.pto-track) #6366f1 The ticked rail takes this colour; the focus ring uses #a5b4fc.
Texts and currency (HTML) Monthly, Annual, $, / month, Billed $… / year All text lives in the HTML: put .pto-cur before or after [data-pto-digits], translate the sentence read by screen readers (.pto-vh) and the checkbox name (aria-label).

FAQ

No, the script reads whole numbers. For $9.99, show 10 in the counter or add the cents as fixed text next to .pto-amount; only the whole part will roll.
Not as is: each [data-pto] card listens to its own checkbox. For a shared switch, hide the cards' switches and, when yours changes, tick each checkbox and send it dispatchEvent(new Event('change')).
Their system asks for less motion (prefers-reduced-motion): the script then sets the digits directly. The price, the badge and the yearly line change exactly the same way.