ui-054

Monthly/Annual Pricing Toggle

£5

A monthly/annual pricing toggle: a sliding thumb, a savings chip that Flips in beside Annual, and plan prices that roll like an odometer.

Flip intermediate
5 more details
odometer-counterflip-layoutmicro-interactionvalue-transitionkeyboard-navigation
Monthly/Annual Pricing Toggle - UI component preview

This element reads better at your own screen size than in the frame below:

About this component

The billing switch at the top of a pricing section, and the prices underneath it that it drives. A thumb slides between Monthly and Annual, measured from the option it is moving to, and the label on it lights as the thumb arrives rather than on the click. Choosing Annual Flips a savings chip in beside the switch, with the switch gliding across to make room, and the percentage on it is worked out from the plan prices rather than typed into the copy.

Read the full effect overview

Each plan price rolls like an odometer: every digit column turns through its own 0 to 9 strip on a short stagger, in tabular numerals, and when the number of digits changes the new column grows in and the old one shrinks out so the row eases to its new width. Prices are formatted with Intl.NumberFormat from data attributes on each plan, with an optional figure per currency, so switching pounds to dollars to euros rolls the symbol and the digit count as well. The small billed-yearly line under each price crossfades in with the annual total. It is a radiogroup with arrow keys, Home and End, the new prices are announced once per change and never per digit, and a billing:change event carries the period to the rest of your checkout. Light and dark are one design at two sets of custom properties.

What's included

13 items
  • Monthly / Annual thumb that stays aligned to the selected option through resizes, font loading and theme switches
  • The label on the thumb lights by the thumb's position, so it never changes colour before the thumb arrives
  • Savings chip Flips in beside Annual while the switch glides across to make room
  • Chip percentage derived from the plan prices for the current currency, never hard-coded
  • Odometer prices: each digit column rolls through a 0 to 9 strip on its own short stagger
  • Digit count changes grow and shrink columns so the price row eases to its new width
  • Currency from data attributes, formatted with Intl.NumberFormat, with per-currency figures per plan
  • Billed-yearly line crossfades in under each price with the annual total
  • Rapid toggling and mid-roll currency switches always land on the right figures
  • role="radiogroup" with two role="radio" options, roving tabindex, arrows, Home and End
  • One polite live announcement per change; digit strips aria-hidden with the real value beside them
  • billing:change and billing:currency events plus setPeriod / setCurrency on the element
  • Light and dark themes from one token list, switched with a single data-variant attribute

Perfect for

4 use cases
  • SaaS pricing sections with a monthly and annual price per plan
  • Upgrade and plan-change screens inside an app
  • Pricing pages that sell in more than one currency
  • Checkout steps where the billing period is chosen before payment

How it works

4 sections

An odometer per price

The script replaces each price with one slot per character of the formatted string. Each digit is a reel of the ten figures behind a clip, and a change rolls the reel to the new figure. Old and new strings are matched from the right, so units roll into units, and each column starts a few hundredths of a second after the one to its left.

Width that eases instead of jumping

When a price gains a digit, the new column is inserted at its natural width, measured, and tweened open from zero; a lost column tweens shut and is removed on completion. A symbol that changes (pounds to euros) crossfades inside its slot while the slot's width eases between the two glyphs. Every tween overwrites the one before it on the same column, which is why hammering the switch or changing currency mid-roll still settles on the right figures.

The chip Flips in

The switch and the chip sit centred together. Showing the chip records the switch's position with Flip.getState, un-hides the chip and lets Flip.from glide the switch to its new place while the chip scales in. Hiding it pins the chip absolutely where it stands, so the switch can re-centre while the chip fades, and only then sets hidden.

Announced once, not per digit

Every rolling strip is aria-hidden and a visually hidden span beside it holds the real price, updated the moment it changes. A single polite live region reads the new prices a moment after the input comes to rest, so four fast toggles are one announcement.

Plugins Flip
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, 15 September 2026. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Monthly/Annual Pricing Toggle opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects.

£5 Standard license, unlimited projects

Browse UI elements

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for every colour

  • assets/script.js: readable, commented source with an onReady guard

  • A thumb measured from the option it is moving to (offsetLeft / offsetWidth), re-measured on resize, after web fonts load and after a theme switch

  • The label on the thumb lights by the thumb's position, so it never changes colour before the thumb arrives under it

  • A savings chip that Flips in beside Annual while the switch glides across to make room; its percentage is worked out from your plan prices

  • Odometer prices: every digit column rolls through a 0 to 9 strip on its own short stagger, in tabular numerals

  • When the number of digits changes (£95 to $125) the new column grows in and the row eases to its new width

  • Prices formatted with Intl.NumberFormat from data attributes, with an optional figure per currency per plan

  • A billed-yearly line under each price that crossfades in with the annual total

  • Four fast toggles, or a currency change mid-roll, still settle on the right figures

  • role="radiogroup" with two role="radio" buttons and a roving tabindex; one polite live region

Quick Start

1. Add to your <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

Flip is optional. Without it the chip still appears and disappears; the switch simply does not glide.

3. Copy the component markup from index.html into your page (between the Component starts here and Component ends here comments). The script looks for:

  • [data-billing-toggle]: the root. data-period="monthly|annual" sets the starting period, data-currency="GBP" the currency the base prices are in (any ISO 4217 code), and data-locale="en-GB" the number format (defaults to the page's lang)
  • [data-bpt-switch]: the role="radiogroup", holding [data-bpt-thumb] and two [data-bpt-option="monthly|annual"] buttons
  • [data-bpt-save] (optional): the chip, with [data-bpt-save-value] inside it for the percentage. Start it hidden
  • [data-bpt-plan]: one per plan, with [data-bpt-name], [data-bpt-amount] (the price text, which is also the no-JavaScript fallback) and optionally the two notes [data-bpt-note="monthly"] and [data-bpt-note="annual"] (start the annual one hidden), the second holding [data-bpt-yearly]
  • [data-bpt-live]: a visually hidden role="status" element for the announcement

The plan attributes are the data. Each plan carries data-monthly and data-annual, the price per month in the root's data-currency. For every other currency you sell in, add data-monthly-usd, data-annual-eur and so on (lower-case code). A plan with no figure for a currency keeps its base number, so set them all or none. The price text, the yearly total and the chip's percentage are all computed from these; nothing is hard-coded in the script.

The toolbar strip and the .stage wrapper in index.html are demo furniture, not part of the component: leave them, their CSS and the inline demo script behind.

Driving it from your own code

Code snippet omitted: it ships with the download.

Every instance is also listed on window.billingToggles.

Keyboard & Accessibility

Key What it does
Tab Moves into the switch, onto the selected option only
ArrowRight / ArrowDown Selects the next period (wraps) and moves focus with it
ArrowLeft / ArrowUp Selects the previous period (wraps)
Home / End Selects Monthly / Annual
Enter / Space Selects the focused option (native button)
  • Roles: role="radiogroup" with an aria-label, two role="radio" buttons with aria-checked and a roving tabindex (0 on the selected option, -1 on the other). The thumb is aria-hidden.
  • Announced: a polite role="status" region reads the period and the new prices ("Annual billing, save 20 percent. Starter £12 a month, Team £32 a month, Business £76 a month, billed yearly.") once, a moment after the input comes to rest. Four fast toggles are one announcement, never one per digit.
  • Each rolling price is aria-hidden, with a visually hidden span beside it that holds the real figure and updates the instant it changes, so reading the card gives the current price.
  • The hidden billing note is aria-hidden and visibility: hidden once it has faded.
  • prefers-reduced-motion: the same end states with every duration at zero. Prices change in place, the thumb jumps, the chip appears. The component stays fully usable.
  • Focus ring: ink on the light theme (yellow cannot clear 3:1 against a light ground), the accent on the dark theme.
  • Without JavaScript the base prices show, and the checked option paints its own selected state.

Themes

Ships in two themes, one design at two sets of token values: light (the default) and dark. Pick one with the attribute:

Code snippet omitted: it ships with the download.

In your own page you can put the attribute on any ancestor instead; just change the body[data-variant="..."] selectors in assets/style.css to match. The script never reads the theme name.

The tokens to re-value for your brand, at the top of each block:

  • --accent / --accent-ink: the savings chip (yellow with ink text in both themes)
  • --thumb / --thumb-ink: the selected option (ink in light, chalk in dark)
  • --focus-ring
  • --ground, --ground-2, --raised, --ink, --ink-2, --ink-3, --line: the surfaces and text the switch and cards sit on
  • --radius, --radius-sm, --shadow, --font

Every text colour is a solid value, so a contrast checker can measure it.

How It Works

The odometer. The script replaces each price with one slot per character of the Intl.NumberFormat string. A digit slot holds an invisible 0 that sets its width and height, and behind a clip a strip of the ten figures that GSAP moves with yPercent. Old and new strings are matched from the right, so units always roll into units, and each column starts 0.035s after the one to its left, each card 0.06s after the one before it.

Width that eases. A column that appears is inserted at its natural width, measured with offsetWidth and tweened open from zero; one that disappears tweens shut and is removed in onComplete. A currency symbol that changes crossfades inside its slot while the slot's width eases between the two glyphs. Every tween uses overwrite: true on its own column, which is what makes rapid toggling and a mid-roll currency change land on the right figures instead of stacking.

The thumb. It sits at left: 0 with no CSS transform, and x and width come from the target option's offsetLeft and offsetWidth. That measurement ignores CSS zoom, which getBoundingClientRect would not. An onUpdate lights whichever label is nearest the thumb's centre.

The chip. Showing it records the switch with Flip.getState, un-hides the chip and runs Flip.from so the switch glides to its new centred position while the chip scales in. Hiding it pins the chip absolutely where it stands, so the switch can re-centre while it fades, and only then sets hidden, so an invisible chip never holds space.

Customisation

  • Timings are constants at the top of assets/script.js: ROLL, COLUMN_STAGGER, CARD_STAGGER, THUMB, CHIP and ANNOUNCE_DELAY.
  • The price size is .bpt-amount { font-size }; everything in the roll scales with it.
  • Change the /mo suffix and the note copy in the markup; the script only writes the figure inside [data-bpt-yearly].
  • Fractional prices work: data-monthly="9.99" formats with two decimals and the separator gets its own slot.

Requirements

  • GSAP 3.12+ (core, plus Flip for the chip's glide)
  • A browser with Intl.NumberFormat narrowSymbol support (every current browser)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.

Your cart

Your cart is empty

The Vault £99

The Vault library, plus future additions to the library.