# Monthly/Annual Pricing Toggle

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

Canonical: https://gsapvault.com/ui-elements/billing-period-toggle
Live demo: https://gsapvault.com/demos/billing-period-toggle/index.html

| Property | Value |
|----------|-------|
| Type | ui-element |
| Tier | paid |
| Price | £5 |
| Difficulty | intermediate |
| Plugins | Flip |
| Techniques | odometer-counter, flip-layout, micro-interaction, value-transition, keyboard-navigation |
| Uses Lenis | No |

## Lighthouse, as measured

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

| Category | Score |
|----------|-------|
| Accessibility | 100 |
| Best practices | 100 |

No performance score is published: it depends on the deployment (server compression, caching, CDN, connection and device) rather than on the code, so measure it where it will live.

## Overview

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.

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.

## Features

- 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

## 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

## Vibe-Code Ready Setup

This UI element includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

### 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.

## Documentation

How this UI element works and how it goes into a page. The reference you use once you own the files (worked examples, events, the programmatic API, the class list) ships with the download.

### 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

## What You Get

- `index.html`: working demo page
- `assets/script.js`: commented, readable source
- `assets/style.css`: effect styles
- `README.md`: full documentation with examples and framework integration notes
- `START-HERE-AI.md`: product-specific copy-paste prompt for AI-assisted setup
- `LICENSE.txt`: standard license terms
- Lifetime updates: re-download anytime from your library

## Get the Code

This is a premium UI element. The standard license costs £5 one-time and covers unlimited personal and commercial projects with no attribution required for our code. Bundled third-party assets retain their own licences and attribution requirements. The only restrictions: no redistribution of the code itself and no competing effect libraries.

- [Buy Monthly/Annual Pricing Toggle](https://gsapvault.com/ui-elements/billing-period-toggle)
- [The Vault (£99 one-time, best value): every collection in the Vault library, plus future items added to those collections](https://gsapvault.com/effects)

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
