# Price Switch (Moonarc)

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.

- Import: `import PriceSwitch from '@moonarc/core/PriceSwitch'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/price-switch.json`
- Tier A · category data · trigger click
- Readout: `<PriceSwitch monthly={12} yearly={115}>`
- Browser support: newly (Chrome 125 · Firefox 129 · Safari 17.5); 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
- Measured cost: 0 B JS (CSS 8.9 kB raw)
- Page: https://moonarc.dev/components/price-switch/

## Props

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

## Usage

```astro
<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" />
```

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

## Craft

- The toggle is two radios: keyboard, focus ring and one-of-two are the browser's. :has() on the wrapper turns the checked one into --ma-nf-value on every plan, and NumberFlow does the rest.
- Prices roll rather than swap, so the eye sees how far apart the two amounts are; a swap hides that.
- The badge waits until yearly is chosen and enters through @starting-style: information arrives at the moment it becomes true, not as a permanent nag beside the label.
- The period label crosses the thumb's direction (/mo leaves upward while /yr arrives from below), the same enter/exit asymmetry as everything else here.
- Both prices exist as visually hidden text and the one for the chosen period is the only one not display: none, so a screen reader hears the right amount while the rolling digits stay aria-hidden.
- The digits need round() and mod() (NumberFlow). In an engine with :has() and without them (Chrome 105 to 124) the rolling number, the symbol and the period are hidden and that same text is the visible price, so the amount and the period always agree.

## Replaces

- pricing toggles (motion.dev, Tailwind Plus)
- framer-motion price flips

## Source

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

```
