Pricing Period Toggle
Monthly/yearly switch with a sliding thumb, a savings badge and prices that count to the new value.
Preview and code
index.html
<div class="pt">
<div class="pt-switch" role="radiogroup" aria-label="Billing period">
<span class="pt-thumb" aria-hidden="true"></span>
<button type="button" role="radio" aria-checked="true" data-p="m">Monthly</button>
<button type="button" role="radio" aria-checked="false" data-p="y">Yearly <em>2 months free</em></button>
</div>
<div class="pt-card"><p class="pt-name">Pro</p><p class="pt-price"><span id="pt-amt">12</span><small id="pt-per">/ month</small></p>
<p class="pt-note" id="pt-note">Billed monthly. Cancel any time.</p></div>
</div>
styles.css
.pt{display:grid;gap:16px;justify-items:center;width:min(340px,100%)}
.pt-switch{position:relative;display:grid;grid-template-columns:1fr 1fr;padding:4px;border-radius:999px;background:color-mix(in srgb,var(--ink) 7%,transparent);border:1px solid var(--line);width:100%}
.pt-thumb{position:absolute;top:4px;bottom:4px;left:4px;width:calc(50% - 4px);border-radius:999px;background:var(--stage);box-shadow:0 2px 8px rgba(0,0,0,.12);transition:transform .35s cubic-bezier(.2,.8,.2,1)}
.pt[data-p=y] .pt-thumb{transform:translateX(100%)}
.pt-switch button{position:relative;border:0;background:none;padding:9px 10px;font:600 13px/1.2 system-ui,sans-serif;color:var(--muted);cursor:pointer;border-radius:999px}
.pt-switch button[aria-checked=true]{color:var(--ink)}
.pt-switch button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.pt-switch em{font-style:normal;font-size:10px;font-weight:700;color:var(--accent);margin-left:4px}
.pt-card{width:100%;border:1px solid var(--line);border-radius:16px;padding:18px 20px;background:var(--stage)}
.pt-name{margin:0;font-weight:600;color:var(--muted);font-size:13px}
.pt-price{margin:4px 0;font:700 40px/1.1 system-ui,sans-serif;letter-spacing:-.02em;color:var(--ink)}
.pt-price::before{content:"$";font-size:22px;vertical-align:top;margin-right:2px}
.pt-price small{font-size:14px;font-weight:500;color:var(--muted);margin-left:6px}
.pt-note{margin:0;font-size:13px;color:var(--muted)}
@media (prefers-reduced-motion:reduce){.pt-thumb{transition:none}}
script.js
const root=document.querySelector('.pt'),amt=document.getElementById('pt-amt'),per=document.getElementById('pt-per'),note=document.getElementById('pt-note');
const P={m:{price:12,per:'/ month',note:'Billed monthly. Cancel any time.'},y:{price:120,per:'/ year',note:'Billed yearly: $10 a month.'}};
const still=matchMedia('(prefers-reduced-motion: reduce)').matches;let cur=12,raf;
function count(to){cancelAnimationFrame(raf);if(still){amt.textContent=to;cur=to;return}const from=cur,t0=performance.now();
const step=t=>{const k=Math.min((t-t0)/450,1),e=1-Math.pow(1-k,3);amt.textContent=Math.round(from+(to-from)*e);if(k<1)raf=requestAnimationFrame(step);else cur=to};raf=requestAnimationFrame(step)}
const btns=[...document.querySelectorAll('.pt-switch button')];
function pick(p){root.dataset.p=p;btns.forEach(b=>b.setAttribute('aria-checked',b.dataset.p===p));per.textContent=P[p].per;note.textContent=P[p].note;count(P[p].price)}
btns.forEach(b=>{b.addEventListener('click',()=>pick(b.dataset.p));b.addEventListener('keydown',e=>{if(['ArrowLeft','ArrowRight'].includes(e.key)){e.preventDefault();const n=btns.find(x=>x!==b);n.focus();pick(n.dataset.p)}})});
pricing-period-toggle.tsx
"use client";
import { useEffect, useRef, useState } from "react";
type Period = "monthly" | "yearly";
const css = `
.pt{display:grid;gap:16px;justify-items:center;width:min(340px,100%);font-family:system-ui,sans-serif;color:#0b1020}
.pt-switch{position:relative;display:grid;grid-template-columns:1fr 1fr;padding:4px;border-radius:999px;background:#eef1f7;border:1px solid #d8deeb;width:100%}
.pt-thumb{position:absolute;top:4px;bottom:4px;left:4px;width:calc(50% - 4px);border-radius:999px;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.12);transition:transform .35s cubic-bezier(.2,.8,.2,1)}
.pt-switch[data-p=yearly] .pt-thumb{transform:translateX(100%)}
.pt-switch button{position:relative;border:0;background:none;padding:9px 10px;font:600 13px/1.2 system-ui,sans-serif;color:#5c627c;cursor:pointer;border-radius:999px}
.pt-switch button[aria-checked=true]{color:#0b1020}
.pt-switch button:focus-visible{outline:2px solid #1659f5;outline-offset:2px}
.pt-switch em{font-style:normal;font-size:10px;font-weight:700;color:#1659f5;margin-left:4px}
@media (prefers-reduced-motion:reduce){.pt-thumb{transition:none}}`;
function useCountTo(value: number) {
const [shown, setShown] = useState(value);
const from = useRef(value);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setShown(value); from.current = value; return; }
const start = from.current, t0 = performance.now();
let raf = 0;
const step = (t: number) => {
const k = Math.min((t - t0) / 450, 1);
const v = Math.round(start + (value - start) * (1 - Math.pow(1 - k, 3)));
setShown(v);
if (k < 1) raf = requestAnimationFrame(step); else from.current = value;
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [value]);
return shown;
}
export function PricingPeriodToggle({ monthly = 12, yearly = 120, badge = "2 months free", onChange }:
{ monthly?: number; yearly?: number; badge?: string; onChange?: (p: Period) => void }) {
const [period, setPeriod] = useState<Period>("monthly");
const price = useCountTo(period === "monthly" ? monthly : yearly);
const pick = (p: Period) => { setPeriod(p); onChange?.(p); };
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); pick(period === "monthly" ? "yearly" : "monthly"); }
};
return (
<div className="pt">
<style>{css}</style>
<div className="pt-switch" role="radiogroup" aria-label="Billing period" data-p={period} onKeyDown={onKey}>
<span className="pt-thumb" aria-hidden="true" />
<button type="button" role="radio" aria-checked={period === "monthly"} onClick={() => pick("monthly")}>Monthly</button>
<button type="button" role="radio" aria-checked={period === "yearly"} onClick={() => pick("yearly")}>Yearly <em>{badge}</em></button>
</div>
<p style={{ margin: 0, font: "700 40px/1.1 system-ui", letterSpacing: "-.02em" }}>
${price}<small style={{ fontSize: 14, fontWeight: 500, color: "#5c627c", marginLeft: 6 }}>/ {period === "monthly" ? "month" : "year"}</small>
</p>
</div>
);
}
Install
npx shadcn@latest add https://ownlyplans.com/r/bhaskar/pricing-period-toggle.json
Adds the React files to components/ownlyplans/ in a project set up with shadcn and Tailwind CSS. Or copy them from the Code tab.
About
The small control that decides how people read your pricing page. The thumb slides, the price rolls to its new value and the per-period label updates, so the change is obvious without a page jump. It is a real radio group, so keyboard and screen reader users get the same control.
The prices in the preview are placeholders: pass your own.