Skip to content

Pricing Toggle Switch

A monthly/yearly billing switch with a springy, draggable thumb and a savings badge. Prices roll digit by digit like an odometer, and an optional plans prop turns it into a three-card pricing table with a border beam on the recommended plan. No dependencies.

DOM + CSS0 deps1 file · 17 KB TSXreduced-motion aware

Freeminimaleditorial
Preview

Props

29
290
Show three plans
No runtime dependencies
"use client";

import { useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";

export type PricingPlan = {
  name: string;
  monthlyPrice: number;
  yearlyPrice: number;
  description?: string;
  features?: string[];
  highlighted?: boolean;
  badge?: string;
  cta?: string;
};

const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([\d\s.,%/-]+\))$/i;

const CSS = [
  "@property --ptsw-a{syntax:'<angle>';inherits:false;initial-value:0deg}",
  ".ptsw{--ptsw-spring:cubic-bezier(.34,1.56,.64,1);--ptsw-out:cubic-bezier(.2,.8,.2,1);box-sizing:border-box;display:flex;flex-direction:column;align-items:center;gap:20px;color:inherit;font-variant-numeric:tabular-nums}",
  ".ptsw *,.ptsw *::before,.ptsw *::after{box-sizing:border-box}",
  ".ptsw[data-single]{padding:32px;border-radius:16px;box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 14%,transparent)}",
  ".ptsw[data-plans]{width:100%;gap:28px;container-type:inline-size}",
  ".ptsw-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}",
  /* toggle row */
  ".ptsw-row{display:flex;align-items:center;gap:12px}",
  ".ptsw-lab{display:inline-flex;align-items:center;gap:8px;font-size:.875rem;font-weight:500;line-height:1;cursor:pointer;user-select:none;-webkit-user-select:none}",
  ".ptsw-lt{opacity:.5;transition:opacity .3s ease}",
  ".ptsw-lab[data-on] .ptsw-lt{opacity:1}",
  ".ptsw-badge{display:inline-block;padding:4px 8px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;white-space:nowrap;color:var(--ptsw-c);background:color-mix(in srgb,var(--ptsw-c) 14%,transparent);transition:background-color .3s ease,color .3s ease,transform .5s var(--ptsw-spring)}",
  ".ptsw-lab[data-on] .ptsw-badge{color:#fff;background:var(--ptsw-c);transform:scale(1.05)}",
  /* switch */
  ".ptsw-sw{position:relative;flex:none;width:56px;height:32px;margin:0;padding:0;border:0;border-radius:999px;cursor:pointer;color:inherit;background:color-mix(in srgb,currentColor 18%,transparent);box-shadow:inset 0 1px 2px rgba(0,0,0,.22),inset 0 0 0 1px color-mix(in srgb,currentColor 12%,transparent);-webkit-tap-highlight-color:transparent;touch-action:pan-y}",
  ".ptsw-sw::before{content:'';position:absolute;inset:0;border-radius:inherit;background:linear-gradient(180deg,color-mix(in srgb,var(--ptsw-c) 82%,#fff),var(--ptsw-c));box-shadow:0 6px 18px -6px var(--ptsw-c),inset 0 1px 0 rgba(255,255,255,.28);opacity:0;transition:opacity .35s ease}",
  ".ptsw-sw::after{content:'';position:absolute;inset:-6px -4px}",
  ".ptsw-sw[aria-checked=true]::before{opacity:1}",
  ".ptsw-th{position:absolute;left:4px;top:4px;width:var(--w,24px);height:24px;border-radius:999px;background:#fff;box-shadow:0 2px 6px rgba(0,0,0,.35),0 0 0 .5px rgba(0,0,0,.12);transition:transform .55s var(--ptsw-spring),width .3s var(--ptsw-spring)}",
  ".ptsw-sw[aria-checked=true] .ptsw-th{transform:translateX(calc(48px - var(--w,24px)))}",
  ".ptsw-sw:active{--w:31px}",
  ".ptsw-sw:focus-visible,.ptsw-cta:focus-visible{outline:2px solid var(--ptsw-c)!important;outline-offset:3px!important}",
  ".ptsw-sw:focus-visible{border-radius:999px}",
  /* rolling price */
  ".ptsw-price{display:flex;align-items:flex-start;font-size:var(--ptsw-fs,2.25rem);font-weight:800;letter-spacing:-.035em;line-height:1.15}",
  ".ptsw-cur{margin:.12em .08em 0 0;font-size:.58em;font-weight:700;opacity:.55;line-height:1}",
  ".ptsw-num{display:inline-flex;height:1.15em}",
  ".ptsw-d{position:relative;display:block;overflow:hidden;height:1.15em;width:.62em;text-align:center;transition:width .55s var(--ptsw-out),opacity .4s ease;-webkit-mask-image:linear-gradient(transparent,#000 12%,#000 88%,transparent);mask-image:linear-gradient(transparent,#000 12%,#000 88%,transparent)}",
  ".ptsw-d[data-s]{width:.3em;-webkit-mask-image:none;mask-image:none;line-height:1.15}",
  ".ptsw-d[data-e]{width:0;opacity:0}",
  ".ptsw-st{display:block;transition:transform .85s var(--ptsw-out);transition-delay:calc(var(--p,0)*45ms)}",
  ".ptsw-st>span{display:block;height:1.15em;line-height:1.15}",
  ".ptsw-per{display:inline-grid;align-self:flex-end;margin:0 0 .3em .3em;font-size:.34em;font-weight:500;letter-spacing:0;line-height:1;opacity:.5}",
  ".ptsw-per>span,.ptsw-bill>span{grid-area:1/1;transition:opacity .3s ease,transform .45s var(--ptsw-out)}",
  ".ptsw-per>span[data-off],.ptsw-bill>span[data-off]{opacity:0;transform:translateY(-.4em)}",
  ".ptsw-per>span[data-off='1'],.ptsw-bill>span[data-off='1']{transform:translateY(.4em)}",
  /* plans */
  ".ptsw-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:10px;width:100%;margin:0;padding:0;list-style:none}",
  ".ptsw-card{position:relative;isolation:isolate;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:2px 16px;padding:14px 16px;border-radius:18px;background:color-mix(in srgb,currentColor 4%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 11%,transparent);transition:transform .45s var(--ptsw-out),box-shadow .3s ease}",
  ".ptsw-card>*{position:relative;z-index:2}",
  ".ptsw-card::after{content:'';position:absolute;inset:0;border-radius:inherit;background:radial-gradient(260px circle at var(--mx,50%) var(--my,0%),color-mix(in srgb,currentColor 10%,transparent),transparent 70%);opacity:0;transition:opacity .3s ease;pointer-events:none;z-index:0}",
  "@media (hover:hover){.ptsw-card:hover{transform:translateY(-3px)}.ptsw-card:hover::after{opacity:1}}",
  ".ptsw-card[data-hl]{background:linear-gradient(180deg,color-mix(in srgb,var(--ptsw-c) 11%,transparent),color-mix(in srgb,currentColor 3%,transparent) 65%);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ptsw-c) 38%,transparent),0 26px 60px -34px var(--ptsw-c)}",
  ".ptsw-card[data-hl]::before{content:'';position:absolute;inset:0;z-index:1;padding:1.5px;border-radius:inherit;pointer-events:none;background:conic-gradient(from var(--ptsw-a),transparent 0 60%,var(--ptsw-c) 82%,color-mix(in srgb,var(--ptsw-c) 40%,#fff) 90%,transparent 100%);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0);animation:ptsw-spin 5s linear infinite}",
  "@keyframes ptsw-spin{to{--ptsw-a:360deg}}",
  ".ptsw-head{min-width:0}",
  ".ptsw-name{display:flex;align-items:center;gap:8px;margin:0;font-size:1rem;font-weight:600;line-height:1.3}",
  ".ptsw-tag{padding:3px 8px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;white-space:nowrap;color:#fff;background:var(--ptsw-c)}",
  ".ptsw-desc{margin:2px 0 0;font-size:.8125rem;line-height:1.4;opacity:.55}",
  ".ptsw-card .ptsw-price{--ptsw-fs:var(--ptsw-pfs,1.75rem)}",
  ".ptsw-card .ptsw-per{font-size:.42em}",
  ".ptsw-bill,.ptsw-feat,.ptsw-cta{display:none}",
  ".ptsw-bill{margin:6px 0 0;font-size:.75rem;line-height:1.3;opacity:.55}",
  ".ptsw-feat{margin:0;padding:0;list-style:none}",
  ".ptsw-feat li{display:flex;align-items:center;gap:10px;font-size:.875rem;line-height:1.3;padding:5px 0}",
  ".ptsw-feat svg{flex:none;width:16px;height:16px;color:var(--ptsw-c)}",
  ".ptsw-cta{appearance:none;width:100%;min-height:44px;margin:0;padding:12px 16px;border:0;border-radius:12px;cursor:pointer;color:inherit;font:inherit;font-size:.875rem;font-weight:600;background:color-mix(in srgb,currentColor 8%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,currentColor 14%,transparent);transition:background-color .25s ease,transform .25s var(--ptsw-spring),box-shadow .25s ease;-webkit-tap-highlight-color:transparent}",
  ".ptsw-cta:hover{background:color-mix(in srgb,currentColor 14%,transparent)}",
  ".ptsw-cta:active{transform:scale(.97)}",
  ".ptsw-card[data-hl] .ptsw-cta{color:#fff;background:var(--ptsw-c);box-shadow:inset 0 1px 0 rgba(255,255,255,.3),0 10px 24px -10px var(--ptsw-c)}",
  ".ptsw-card[data-hl] .ptsw-cta:hover{filter:brightness(1.08)}",
  "@container (min-width:620px){",
  ".ptsw-grid{grid-template-columns:repeat(var(--ptsw-n,3),minmax(0,1fr));gap:14px}",
  ".ptsw-card{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:26px 24px 22px}",
  ".ptsw-card .ptsw-price{--ptsw-fs:var(--ptsw-pfs,3rem);margin-top:22px}",
  ".ptsw-card .ptsw-per{font-size:.34em}",
  ".ptsw-bill{display:grid}",
  ".ptsw-feat{display:block;margin:20px 0 22px;flex:1}",
  ".ptsw-cta{display:block}",
  ".ptsw-name{font-size:1.0625rem}",
  "}",
  "@media (prefers-reduced-motion:reduce){",
  ".ptsw-th,.ptsw-sw::before,.ptsw-lt,.ptsw-badge,.ptsw-d,.ptsw-st,.ptsw-per>span,.ptsw-bill>span,.ptsw-card,.ptsw-card::after,.ptsw-cta{transition-duration:.01s!important;transition-delay:0s!important}",
  ".ptsw-card[data-hl]::before{animation:none!important;--ptsw-a:200deg}",
  ".ptsw-card:hover{transform:none!important}",
  "}",
].join("\n");

const num = (n: number) => (Number.isFinite(n) ? n : 0);

function savingLabel(monthly: number, yearly: number, mode: "percent" | "months"): string | null {
  const m = num(monthly);
  const y = num(yearly);
  if (m <= 0) return null;
  const pct = Math.round((1 - y / (m * 12)) * 100);
  if (mode === "months") {
    const free = Math.round(12 - y / m);
    if (free >= 1) return free + (free === 1 ? " month free" : " months free");
  }
  return pct > 0 ? "Save " + pct + "%" : null;
}

function Swap({ on, a, b, className }: { on: boolean; a: string; b: string; className: string }) {
  return (
    <span className={className}>
      <span data-off={on ? "" : undefined} aria-hidden="true">
        {a}
      </span>
      <span data-off={on ? undefined : "1"} aria-hidden="true">
        {b}
      </span>
    </span>
  );
}

function Price({ monthly, yearly, on, currency }: { monthly: number; yearly: number; on: boolean; currency: string }) {
  const a = String(num(monthly));
  const b = String(num(yearly));
  const s = on ? b : a;
  const len = Math.max(a.length, b.length);
  const slots: { p: number; ch: string }[] = [];
  for (let p = len - 1; p >= 0; p--) slots.push({ p, ch: s.charAt(s.length - 1 - p) });
  return (
    <div className="ptsw-price">
      <span className="ptsw-sr">
        {currency}
        {s} per {on ? "year" : "month"}
      </span>
      <span className="ptsw-cur" aria-hidden="true">
        {currency}
      </span>
      <span className="ptsw-num" aria-hidden="true">
        {slots.map(({ p, ch }) => {
          const digit = ch >= "0" && ch <= "9" ? Number(ch) : -1;
          if (digit >= 0) {
            return (
              <span key={p} className="ptsw-d">
                <span className="ptsw-st" style={{ transform: "translateY(-" + digit * 10 + "%)", "--p": p } as CSSProperties}>
                  {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((d) => (
                    <span key={d}>{d}</span>
                  ))}
                </span>
              </span>
            );
          }
          return (
            <span key={p} className="ptsw-d" data-s="" data-e={ch === "" ? "" : undefined}>
              {ch}
            </span>
          );
        })}
      </span>
      <Swap className="ptsw-per" on={on} a="/mo" b="/yr" />
    </div>
  );
}

const CHECK = (
  <svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
    <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
  </svg>
);

export function PricingToggleSwitch({
  monthlyPrice = 29,
  yearlyPrice = 290,
  currency = "$",
  yearly,
  defaultYearly = false,
  onYearlyChange,
  plans,
  onSelectPlan,
  saveLabel = "percent",
  monthlyLabel = "Monthly",
  yearlyLabel = "Yearly",
  color = "#ef4444",
  className = "",
}: {
  monthlyPrice?: number;
  yearlyPrice?: number;
  currency?: string;
  yearly?: boolean;
  defaultYearly?: boolean;
  onYearlyChange?: (yearly: boolean) => void;
  plans?: PricingPlan[];
  onSelectPlan?: (plan: PricingPlan, yearly: boolean) => void;
  saveLabel?: "percent" | "months";
  monthlyLabel?: string;
  yearlyLabel?: string;
  color?: string;
  className?: string;
}) {
  const [inner, setInner] = useState(defaultYearly);
  const on = yearly ?? inner;
  const down = useRef<number | null>(null);
  const dragged = useRef(false);

  const set = (v: boolean) => {
    if (yearly === undefined) setInner(v);
    if (v !== on) onYearlyChange?.(v);
  };

  const accent = SAFE_COLOR.test(color.trim()) ? color.trim() : "#ef4444";
  const hasPlans = !!plans && plans.length > 0;
  const ref = hasPlans ? (plans.find((p) => p.highlighted) ?? plans[0]) : undefined;
  const badge = ref ? savingLabel(ref.monthlyPrice, ref.yearlyPrice, saveLabel) : savingLabel(monthlyPrice, yearlyPrice, saveLabel);

  const onMove = (e: ReactPointerEvent<HTMLElement>) => {
    const el = e.currentTarget;
    const r = el.getBoundingClientRect();
    const k = r.width ? el.offsetWidth / r.width : 1;
    el.style.setProperty("--mx", (e.clientX - r.left) * k + "px");
    el.style.setProperty("--my", (e.clientY - r.top) * k + "px");
  };

  return (
    <div
      className={"ptsw " + className}
      data-single={hasPlans ? undefined : ""}
      data-plans={hasPlans ? "" : undefined}
      style={{ "--ptsw-c": accent, "--ptsw-n": hasPlans ? Math.min(plans.length, 4) : 1 } as CSSProperties}
    >
      <style>{CSS}</style>
      <div className="ptsw-row">
        <span className="ptsw-lab" data-on={on ? undefined : ""} onClick={() => set(false)}>
          <span className="ptsw-lt">{monthlyLabel}</span>
        </span>
        <button
          type="button"
          role="switch"
          aria-checked={on}
          aria-label="Bill yearly"
          className="ptsw-sw"
          onClick={() => {
            if (dragged.current) {
              dragged.current = false;
              return;
            }
            set(!on);
          }}
          onPointerDown={(e) => {
            down.current = e.clientX;
            dragged.current = false;
            e.currentTarget.setPointerCapture?.(e.pointerId);
          }}
          onPointerUp={(e) => {
            if (down.current !== null && Math.abs(e.clientX - down.current) > 10) {
              dragged.current = true;
              set(e.clientX > down.current);
            }
            down.current = null;
          }}
          onPointerCancel={() => {
            down.current = null;
          }}
          onKeyDown={(e) => {
            if (e.key === "ArrowRight") {
              e.preventDefault();
              set(true);
            } else if (e.key === "ArrowLeft") {
              e.preventDefault();
              set(false);
            }
          }}
        >
          <span className="ptsw-th" />
        </button>
        <span className="ptsw-lab" data-on={on ? "" : undefined} onClick={() => set(true)}>
          <span className="ptsw-lt">{yearlyLabel}</span>
          {badge && <span className="ptsw-badge">{badge}</span>}
        </span>
      </div>
      <span className="ptsw-sr" role="status" aria-live="polite">
        {"Billing " + (on ? "yearly" : "monthly")}
      </span>

      {hasPlans ? (
        <ul className="ptsw-grid">
          {plans.map((plan, i) => {
            const saved = Math.round(num(plan.monthlyPrice) * 12 - num(plan.yearlyPrice));
            return (
              <li key={plan.name + i} className="ptsw-card" data-hl={plan.highlighted ? "" : undefined} onPointerMove={onMove}>
                <div className="ptsw-head">
                  <h3 className="ptsw-name">
                    {plan.name}
                    {plan.highlighted && <span className="ptsw-tag">{plan.badge ?? "Most popular"}</span>}
                  </h3>
                  {plan.description && <p className="ptsw-desc">{plan.description}</p>}
                </div>
                <div className="ptsw-pw">
                  <Price monthly={plan.monthlyPrice} yearly={plan.yearlyPrice} on={on} currency={currency} />
                  <Swap
                    className="ptsw-bill"
                    on={on}
                    a="Billed monthly"
                    b={saved > 0 ? "Billed yearly, save " + currency + saved : "Billed yearly"}
                  />
                </div>
                {plan.features && plan.features.length > 0 && (
                  <ul className="ptsw-feat">
                    {plan.features.map((f) => (
                      <li key={f}>
                        {CHECK}
                        {f}
                      </li>
                    ))}
                  </ul>
                )}
                <button type="button" className="ptsw-cta" onClick={() => onSelectPlan?.(plan, on)}>
                  {plan.cta ?? "Get started"}
                </button>
              </li>
            );
          })}
        </ul>
      ) : (
        <Price monthly={monthlyPrice} yearly={yearlyPrice} on={on} currency={currency} />
      )}
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

By default it renders one billing switch and one price, exactly as before. The switch is a real button with role switch and aria-checked: click it, drag the thumb across, press the arrow keys, or click the Monthly and Yearly labels. The thumb travels on a springy cubic-bezier and stretches while pressed. The price is an odometer: every digit is a strip of 0 to 9 that translates to its new value, with the columns staggered from the right so the last digit lands first, and columns collapse or open when the two prices have different lengths. A savings badge is worked out from the two prices, either as a percentage or, with saveLabel set to months, as months free. Pass a plans array and the same switch drives a row of plan cards: each price rolls independently, a billed line crossfades between monthly and yearly, a pointer-following light sits behind the card you hover, and the highlighted plan carries a rotating border beam drawn with a conic gradient and a CSS mask. The cards are columns in a wide container and compact rows in a narrow one, using container queries rather than viewport breakpoints. The component is controlled when you pass yearly and uncontrolled otherwise, and it takes its colours from currentColor plus one accent, so it sits on light or dark pages.

Curator’s note

Rebuilt in October: the price now rolls digit by digit instead of sliding as a block, the thumb is a spring that stretches under your finger, and an optional plans prop gives you the full pricing table. It no longer needs framer-motion.

Pairs well with

Pro

Pricing Page

templates
Pro