Count Up
TextCounts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
A monthly / yearly toggle whose prices roll to the other amount instead of swapping. The thumb slides, the period label changes places, and a "save" badge appears on the yearly option. Two native radios, :has() and NumberFlow. Zero JavaScript; both prices are in the HTML as text for assistive tech.
sample prices
Starter
$12/mo$115/yr
one site
Studio
$48/mo$460/yr
up to ten
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
0 B
CSS 8.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 125 · Firefox 129 · Safari 17.5; needs has, css-math, starting-style, allow-discrete. Elsewhere: without round() and mod() the price is plain text that changes in place with the period; without @starting-style the badge appears without its entrance.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import PriceSwitch from '@moonarc/core/PriceSwitch';
---
<PriceSwitch id="billing" plans={[
{ name: 'Solo', monthly: 12, yearly: 115, note: 'one site' },
{ name: 'Team', monthly: 48, yearly: 460, note: 'up to ten' },
]} save="2 months free" />Owns the file, no dependency. The registry item also installs Number Flow and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/price-switch.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy NumberFlow.astro.
---
/**
* PriceSwitch — monthly or yearly; the prices roll. Two native radios and
* :has(): the checked one sets --ma-nf-value on every plan, so each
* NumberFlow rolls to the other price, the period label swaps, and the
* "save" badge enters through @starting-style. Zero JS. The server HTML
* carries both prices as text for assistive tech; where the engine cannot
* roll the digits (no round() / mod()), that text is the visible price.
*/
import type { HTMLAttributes } from 'astro/types';
import NumberFlow from './NumberFlow.astro';
interface Plan {
name?: string;
monthly: number;
yearly: number;
note?: string;
}
interface Props extends HTMLAttributes<'div'> {
/** Radio group name and id prefix; unique per page. */
id: string;
/** Plans to price. One plan can also be given as monthly + yearly. */
plans?: Plan[];
monthly?: number;
yearly?: number;
/** Currency symbol before the number. */
currency?: string;
/** Period labels after the number. */
periods?: [string, string];
/** Toggle labels. */
labels?: [string, string];
/** Badge shown on the yearly option once it is chosen. */
save?: string;
/** Accessible name of the radio group. */
groupLabel?: string;
}
const { id, plans, monthly, yearly: yearlyPrice, currency = '$', periods = ['/mo', '/yr'], labels = ['Monthly', 'Yearly'], save, groupLabel = 'Billing period', class: className, ...rest } = Astro.props;
const list: Plan[] = plans ?? [{ monthly: monthly ?? 0, yearly: yearlyPrice ?? 0 }];
const digits = Math.max(...list.map((p) => String(Math.floor(Math.max(p.monthly, p.yearly))).length));
const decimals = list.some((p) => !Number.isInteger(p.monthly) || !Number.isInteger(p.yearly)) ? 2 : 0;
const fmt = (n: number) => `${currency}${n.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}`;
---
<div class:list={['ma-price', className]} {...rest}>
<div class="ma-price__toggle" role="radiogroup" aria-label={groupLabel}>
<input type="radio" class="ma-price__input" name={id} id={`${id}-m`} checked />
<label for={`${id}-m`} class="ma-price__opt">{labels[0]}</label>
<input type="radio" class="ma-price__input" name={id} id={`${id}-y`} />
<label for={`${id}-y`} class="ma-price__opt">{labels[1]}{save && <span class="ma-price__save">{save}</span>}</label>
<span class="ma-price__thumb" aria-hidden="true"></span>
</div>
<div class="ma-price__plans">
{list.map((p) => (
<div class="ma-price__plan" style={`--ma-price-monthly:${p.monthly};--ma-price-yearly:${p.yearly}`}>
{p.name && <p class="ma-price__name">{p.name}</p>}
<p class="ma-price__amount">
<span class="ma-price__sr">{fmt(p.monthly)}{periods[0]}</span>
<span class="ma-price__sr ma-price__sr--y">{fmt(p.yearly)}{periods[1]}</span>
<span class="ma-price__currency" aria-hidden="true">{currency}</span>
<NumberFlow value={p.monthly} digits={digits} decimals={decimals} class="ma-price__number" aria-hidden="true" />
<span class="ma-price__period" aria-hidden="true"><span class="ma-price__per">{periods[0]}</span><span class="ma-price__per ma-price__per--y">{periods[1]}</span></span>
</p>
{p.note && <p class="ma-price__note">{p.note}</p>}
</div>
))}
</div>
<slot />
</div>
<style is:global>
@layer components {
:where(.ma-price__toggle) {
position: relative;
display: inline-grid;
grid-template-columns: 1fr 1fr;
padding: 0.2em;
border: 1px solid var(--ma-edge);
border-radius: 999px;
}
:where(.ma-price__input) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
:where(.ma-price__opt) {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5em;
padding: 0.45em 1.1em;
border-radius: 999px;
cursor: pointer;
font-weight: 500;
opacity: var(--ma-dim, 0.7);
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-price__input:checked + .ma-price__opt) {
opacity: 1;
}
:where(.ma-price__input:focus-visible + .ma-price__opt) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
:where(.ma-price__thumb) {
position: absolute;
inset: 0.2em auto 0.2em 0.2em;
width: calc(50% - 0.2em);
border-radius: 999px;
background: var(--ma-edge);
transition: translate var(--ma-duration) var(--ma-ease);
}
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__thumb) {
translate: 100% 0;
}
/* the badge enters when yearly is chosen */
:where(.ma-price__save) {
display: none;
padding: 0.1em 0.55em;
border-radius: 999px;
font-size: 0.75em;
background: var(--ma-ink);
color: var(--ma-panel);
transition:
opacity var(--ma-duration) var(--ma-ease),
scale var(--ma-duration) var(--ma-ease),
display var(--ma-duration) allow-discrete;
}
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__save) {
display: inline-block;
}
@starting-style {
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__save) {
opacity: 0;
scale: 0.6;
}
}
:where(.ma-price__plans) {
display: grid;
gap: 1.5em;
grid-template-columns: repeat(auto-fit, minmax(8em, 1fr));
margin-top: 1.25em;
}
/* the price every NumberFlow in a plan shows */
:where(.ma-price__plan) {
--ma-nf-value: var(--ma-price-monthly);
}
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__plan) {
--ma-nf-value: var(--ma-price-yearly);
}
:where(.ma-price__name) {
margin: 0 0 0.35em;
font-size: 0.875em;
opacity: 0.7;
}
:where(.ma-price__amount) {
display: flex;
align-items: baseline;
gap: 0.1em;
margin: 0;
font-size: 2.25em;
font-weight: 600;
letter-spacing: -0.03em;
line-height: 1;
}
:where(.ma-price__currency) {
font-size: 0.6em;
align-self: flex-start;
margin-top: 0.15em;
}
:where(.ma-price__period) {
display: inline-grid;
margin-left: 0.2em;
font-size: 0.4em;
font-weight: 400;
letter-spacing: 0;
opacity: 0.7;
}
:where(.ma-price__per) {
grid-area: 1 / 1;
transition:
opacity var(--ma-duration-fast) var(--ma-ease-out),
translate var(--ma-duration) var(--ma-ease);
}
:where(.ma-price__per--y) {
opacity: 0;
translate: 0 var(--ma-travel-hover);
}
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__per) {
opacity: 0;
translate: 0 calc(-1 * var(--ma-travel-hover));
}
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__per--y) {
opacity: 1;
translate: 0 0;
}
:where(.ma-price__note) {
margin: 0.5em 0 0;
font-size: 0.875em;
opacity: 0.7;
}
/* the current price as text, for assistive tech */
:where(.ma-price__sr) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
:where(.ma-price__sr--y),
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__sr:not(.ma-price__sr--y)) {
display: none;
}
:where(.ma-price:has(.ma-price__input:nth-of-type(2):checked) .ma-price__sr--y) {
display: block;
}
/* NumberFlow's digits need round() and mod() (the same test as its own). An engine with :has() and without them
(Chrome 105 to 124) kept the monthly digits while the period swapped to /yr: there the price is the text itself */
@supports not (translate: calc(mod(round(down, 7, 1), 10) * 1em)) {
:where(.ma-price__currency),
:where(.ma-price__period) {
display: none;
}
/* one class of weight: the number is also NumberFlow's root, whose own display rule may come later in the page */
:where(.ma-price__amount) .ma-price__number {
display: none;
}
:where(.ma-price__sr) {
position: static;
width: auto;
height: auto;
overflow: visible;
clip-path: none;
}
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-price__thumb),
:where(.ma-price__save),
:where(.ma-price__per) {
transition: none;
}
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
id | string | none | Radio group name and id prefix; unique per page. |
plans | { name?, monthly, yearly, note? }[] | none | Plans to price side by side. Omit it for a single price from monthly and yearly. |
monthly | number | none | Monthly price of the single plan. |
yearly | number | none | Yearly price of the single plan. |
currency | string | '$' | Symbol before the number. |
periods | [string, string] | ['/mo', '/yr'] | Period labels after the number. |
labels | [string, string] | ['Monthly', 'Yearly'] | Toggle labels. |
save | string | none | Badge shown on the yearly option once it is chosen, e.g. "save 20%". |
groupLabel | string | 'Billing period' | Accessible name of the radio group, for a page in another language. |
Prices, thumb and labels change in place; the badge appears without scaling in.
ClientRouterCSS-only; nothing to rebind. The radio state is the page's own.
Replaces: pricing toggles (motion.dev, Tailwind Plus) · framer-motion price flips. See the migration table.
Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
1,284
A number whose digits roll to the new value when it changes.
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json
Tabs with an indicator that slides to the selected one and panels that fade in.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown