Skip to content

Components / Data

Price Switch

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.

Live demo

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.

Measured

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

Baseline · newly available

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.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Number Flow and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/price-switch.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy NumberFlow.astro.

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

Props

PropTypeDefaultDescription
idstringnoneRadio group name and id prefix; unique per page.
plans{ name?, monthly, yearly, note? }[]nonePlans to price side by side. Omit it for a single price from monthly and yearly.
monthlynumbernoneMonthly price of the single plan.
yearlynumbernoneYearly price of the single plan.
currencystring'$'Symbol before the number.
periods[string, string]['/mo', '/yr']Period labels after the number.
labels[string, string]['Monthly', 'Yearly']Toggle labels.
savestringnoneBadge shown on the yearly option once it is chosen, e.g. "save 20%".
groupLabelstring'Billing period'Accessible name of the radio group, for a page in another language.

Reduced motion

Prices, thumb and labels change in place; the badge appears without scaling in.

With ClientRouter

CSS-only; nothing to rebind. The radio state is the page's own.

Why it is built this way

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.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

1,284

A number whose digits roll to the new value when it changes.

newly · Chrome 125 · Firefox 118 · Safari 15.4click
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.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown