CSS Rolling Price Toggle
A CSS rolling price toggle where each digit is a one-line window onto a column of ten numerals. The lh unit positions each reel and :has() swaps the plan.
- Level
- Advanced
- Published
- Updated
- Reading time
- 6 min
Try it
1 live demo
Your browser can't run this demo
It needs :has() and the lh unit, which this browser doesn't support yet. It works in Chrome 109+, Edge 109+, Firefox 121+ and Safari 16.4+.
Needs :has() and the lh unit
$29 a month
$14 a month, billed yearly
The digits roll when the plan changes
Build it
2 snippets to copy
CSS Rolling Price Toggle
HTML
<div class="rp-card">
<div class="rp-switch" role="radiogroup" aria-label="Billing period">
<label class="rp-option"><input class="rp-monthly" type="radio" name="cycle" checked> Monthly</label>
<label class="rp-option"><input class="rp-yearly" type="radio" name="cycle"> Yearly</label>
</div>
<!-- --m and --y are this digit's value on each plan -->
<p class="rp-price" aria-hidden="true">$<span class="rp-digit" style="--m:2;--y:1"
><span class="rp-reel"><span>0</span><span>1</span><span>2</span><span>3</span
><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span
></span></span><span class="rp-digit" style="--m:9;--y:4;--delay:.08s"
><span class="rp-reel"><span>0</span><span>1</span><span>2</span><span>3</span
><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span
></span></span></p>
<p class="rp-spoken rp-spoken-monthly">$29 a month</p>
<p class="rp-spoken rp-spoken-yearly">$14 a month, billed yearly</p>
</div>
CSS
.rp-card {
color: #f0f0f0;
}
.rp-switch {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 999px;
background: #0c0c0d;
}
.rp-option {
padding: .3rem .85rem;
border-radius: 999px;
font-size: .8rem;
cursor: pointer;
}
/* clipped rather than removed, so the radios keep the keyboard */
.rp-option input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.rp-option:has(input:checked) {
color: #16210a;
background: #b8ff57;
}
.rp-option:has(input:focus-visible) {
outline: 2px solid #b8ff57;
outline-offset: 2px;
}
.rp-price {
display: flex;
margin: .5rem 0 0;
font-size: 3rem;
font-weight: 800;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
/* a window one line tall onto a column of ten digits */
.rp-digit {
display: inline-block;
height: 1lh;
overflow: hidden;
--d: var(--m);
}
/* lh is one line of this text, so -3lh shows the digit 3 */
.rp-reel {
display: block;
translate: 0 calc(var(--d) * -1lh);
transition: translate .6s cubic-bezier(.2, .9, .25, 1.15) var(--delay, 0s);
}
.rp-reel span {
display: block;
}
.rp-card:has(.rp-yearly:checked) .rp-digit {
--d: var(--y);
}
/* the rolling digits are hidden from screen readers,
so one of these says the price */
.rp-spoken {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
}
.rp-spoken-yearly {
display: none;
}
.rp-card:has(.rp-yearly:checked) .rp-spoken-monthly {
display: none;
}
.rp-card:has(.rp-yearly:checked) .rp-spoken-yearly {
display: block;
}
@media (prefers-reduced-motion: reduce) {
.rp-reel { transition: none; }
}
Understand it
How it works and 7 properties
The rolling digits here have no JavaScript. Each digit is a narrow window with overflow: hidden over a vertical column of all ten numerals. Sliding the column by a number of line heights shows the right digit. The lh unit ties each offset to the text height. 1lh equals one line of that text, so multiplying a digit value by -1lh always lands on the right row.
:has(.rp-yearly:checked) on the card matches when the yearly radio is checked and sets a custom property on each digit to select the yearly value. Separate visually hidden paragraphs supply the price to screen readers, since the digit columns read as individual characters rather than a number.
How it works
Each .rp-digit holds two custom properties: --m for the monthly digit value and --y for the yearly. A third property, --d, defaults to --m. The .rp-reel inside each digit translates vertically by calc(var(--d) * -1lh), landing on row --d out of the ten stacked spans. Selecting yearly triggers :has(.rp-yearly:checked) on the card, which sets --d: var(--y) on every .rp-digit and moves all reels to their yearly positions. The tens digit moves from 2 to 1 and the units digit moves from 9 to 4.
Each .rp-digit is height: 1lh with overflow: hidden, which clips the column to one row. The .rp-reel inside is a block containing ten <span> elements, each display: block. Translating by a negative lh multiple slides the visible row: digit 0 needs no offset, digit 3 needs -3lh, digit 9 needs -9lh. --m and --y on each .rp-digit hold the per-plan values; --d picks between them. The reel mechanism is related to a CSS text ticker, where words loop vertically in a container clipped to one line.
The selector .rp-card:has(.rp-yearly:checked) matches the card when any descendant with class rp-yearly is a checked radio. It can reach that radio inside a label, which is what keeps the markup flexible. Setting --d: var(--y) on .rp-digit inside the matched card triggers the reels, because the translate on .rp-reel reads --d and recomputes.
The --delay property on the second digit staggers it against the first. The tens digit moves first; the units digit follows a fraction of a second later. Set --delay values on each .rp-digit individually in the HTML.
The prices in this demo are made up. A live pricing component would need a server or JavaScript to inject the current values, because the CSS carries both as static custom properties in the HTML. There is no way in CSS alone to fetch or compute a number from an external source, so this technique fits a fixed set of prices that can be written directly into the markup.
Accessibility notes
Scrolling digits are not readable as a number to assistive technology. Each .rp-digit shows one character, but the full column holds ten numeral spans, so assistive technologies would encounter ten items per digit rather than a single price. The entire price element carries aria-hidden. Two visually hidden <p> elements hold the prices as plain text, clipped to 1px with position: absolute and clip-path: inset(50%). The CSS shows one and suppresses the other based on which radio is checked.
The radio controls use a role of radiogroup and an aria-label on the wrapper, and each label wraps its input. The wrapper's aria-label provides the group name, and each label provides its option's name. :has(input:focus-visible) on the label shows a focus ring. Arrow keys navigate between the two options.
There is no aria-live region on the price. When the plan switches, the visually hidden paragraph updates, but that change is not in a live region. To check the new price, someone using assistive technology needs to navigate to it. The reduced-motion rule removes the transition, so digits snap rather than scroll.
Mistakes worth avoiding
- Adding more digits without staggering them. Three or more digits with no
--delaydifferences all fire at once; each extra digit needs a slightly later--delayin its inline style. - Treating the custom property values as a live feed. The digit values are hardcoded in the HTML as
--mand--y. Any price change requires editing the markup; a dynamic source needs JavaScript to update those properties. - Leaving
aria-hiddenoff the price element. Without it, the digit columns would be in the tree as individual characters rather than a number. - Removing the visually hidden price paragraphs.
aria-hiddensuppresses the rolling display. Remove them and the price disappears entirely from the accessibility tree. - Using a fixed pixel offset instead of
lh. A pixel value breaks when the font size changes or the line height is adjusted. Thelhunit tracks the line height of the same element the digits use.
Ship it
4 features in 4 browsers, 5 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
:has() | 105 | 121 | 15.4 | 105 |
lh unit | 109 | 120 | 16.4 | 109 |
translate | 104 | 72 | 14.1 | 104 |
custom properties | 49 | 31 | 9.1 | 15 |
The lh unit arrived in Chrome 109, Firefox 120, and Safari 16.4. Without it height: 1lh is invalid and all ten digit spans become visible in each column. :has() arrived in Chrome 105, Firefox 121, and Safari 15.4. Without it the yearly rule never matches: digits stay at their monthly values after toggling, and the selected option shows no active style.
What you can build with it
- Monthly and yearly pricing toggles. A plan card where each digit column shows a different value per billing period.
- Unit converters. A value in one unit that rolls to another when a mode changes, with both amounts hardcoded in the markup.
- Savings displays. A card showing a cost two ways, such as a daily rate and a monthly total, with a toggle switching the view while each digit rolls to its new value.
- Promotional price tiers. Two price points for a promotional period, where the rolling digit signals which tier is active.
Frequently asked questions
How do the digits roll without JavaScript?
.rp-digit is clipped to one line via height: 1lh and overflow: hidden. Translating the inner reel by a multiple of 1lh slides the visible character into the window. Selecting yearly triggers :has(.rp-yearly:checked), which swaps a custom property; the translate and transition do the rest.What does the lh unit do?
lh equals one line of text at that element's line height. Each digit in the reel is one line tall, so translating by N * 1lh lands on row N. It follows the line height automatically.How do I add a third digit?
.rp-digit span with --m and --y values for that position, a column of ten digit spans inside, and a --delay slightly later than the second digit's. Update the text in each visually hidden paragraph to reflect the new full price.Why are there two visually hidden paragraphs?
Can I use this for more than two plans?
:has() rule to swap --d to that plan's value, a third visually hidden paragraph with that plan's price, and :has() rules to show it and hide the other two. The HTML also needs a third radio option.