GSAP & Scroll Motion
SaaS Pricing Table Toggle (Monthly/Annual) with GSAP Rolling Number Counter
One switch flips every plan between monthly and annual; each price rolls to its new value with tabular figures in a fixed-width slot, and screen readers hear the final price once, not every frame.
Works in Webflow Framer React Zero CLS
Customize before you copy
Showing the Webflow version
You are a senior frontend engineer building a SaaS pricing section. Add a Monthly / Annual billing toggle whose prices roll to their new values.
Behaviour:
- One toggle controls every plan card. It is a real switch: role="switch" with aria-checked, a visible label ("Billed annually · save 20%"), keyboard operable with Space and Enter.
- Each price stores both values (e.g. data-monthly="49" data-annual="39"). On toggle, the number counts from the old value to the new one over 0.8s with ease "expo.out", while the digits nudge 30% vertically in the direction of change (down when the price drops, up when it rises).
- Under each price, swap the sub-label ("per month" ↔ "per month, billed yearly") and show the yearly total for annual.
Layout and performance:
- Prices use font-variant-numeric: tabular-nums inside a slot with a fixed min-width in ch, so a 2- vs 3-digit price never moves the layout (CLS 0).
- No per-frame React re-renders beyond the changing integer (re-render only when the rounded value changes), or write textContent directly.
- The server HTML shows the monthly prices, so the page is correct before JavaScript.
Accessibility:
- Don't put aria-live on the counting numbers (screen readers would read every frame). Announce once through a single polite live region: "Annual billing selected. Prices updated."
- Reduced motion: swap the numbers instantly.
Deliver the structure, styles and script, then list the three values I'd tweak to make the counter feel faster or more premium.
Target platform: Webflow.
- Build the structure as native Webflow Designer elements with Client-First class names (section_[name], [name]_component, [name]_item). No div soup from an HTML embed unless I ask for one.
- Hook every behaviour to custom attributes (data-gsap="…"), never to class names, so designers can restyle without breaking the script.
- Load GSAP through Webflow's built-in GSAP integration (Site settings) or the jsDelivr CDN tags, and put the init script in Page settings → Before </body> tag, wrapped in Webflow.push(() => { … }).
- Say which parts go in Designer styles, which in a page-head <style>, and which in the body script.
- Designer: pricing_toggle is a Button with data-billing-toggle; each plan's price is a Text Span with data-price, data-monthly and data-annual (CMS-bindable), and the sub-label a Text Span with data-price-note.
Motion timing: "Editorial". Use a base duration of 0.8s, ease "expo.out" (CSS: cubic-bezier(0.16, 1, 0.3, 1)), stagger 0.08s, scrub smoothing 0.8, and springs of stiffness 220 / damping 22, everywhere a duration, ease or spring appears.<!-- Designer:
button.pricing_toggle [data-billing-toggle] [role=switch] [aria-checked=false]
span.pricing_price [data-price] [data-monthly=49] [data-annual=39] -> text "49"
span.pricing_note [data-price-note] -> text "per month" -->
<!-- Page settings > Inside <head> -->
<style>
[data-price] { display: inline-block; min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; }
.pricing_toggle[aria-checked="true"] .pricing_toggle-knob { transform: translateX(100%); }
</style>
<!-- Page settings > Before </body> tag -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.Webflow ||= [];
window.Webflow.push(() => {
const toggle = document.querySelector("[data-billing-toggle]");
if (!toggle) return;
const prices = gsap.utils.toArray("[data-price]");
const notes = gsap.utils.toArray("[data-price-note]");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
// One polite announcement per change, never per frame.
const live = document.createElement("span");
live.setAttribute("aria-live", "polite");
live.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)";
toggle.after(live);
toggle.addEventListener("click", () => {
const annual = toggle.getAttribute("aria-checked") !== "true";
toggle.setAttribute("aria-checked", String(annual));
notes.forEach((n) => (n.textContent = annual ? "per month, billed yearly" : "per month"));
live.textContent = (annual ? "Annual" : "Monthly") + " billing selected. Prices updated.";
prices.forEach((el) => {
const from = Number(el.textContent);
const to = Number(annual ? el.dataset.annual : el.dataset.monthly);
if (reduce || from === to) return (el.textContent = to);
const counter = { v: from };
gsap.to(counter, {
v: to,
duration: 0.8,
ease: "expo.out",
overwrite: true,
onUpdate: () => (el.textContent = Math.round(counter.v)),
});
gsap.fromTo(el, { yPercent: to < from ? -30 : 30, opacity: 0.4 },
{ yPercent: 0, opacity: 1, duration: 0.8, ease: "expo.out", overwrite: "auto" });
});
});
});
</script>3-step implementation guide
Step 1
DOM & attribute setup
Give every price both values (monthly and annual) as attributes or props, render the monthly price in the HTML, and wrap the number in a fixed-width tabular-figures slot so it can't push the layout.
Step 2
Prompt Claude or drop in the snippet
Paste the prompt for your platform or drop in the snippet. The toggle is a real switch (role and aria-checked), and a single polite live region announces the change once instead of reading every frame.
Step 3
Mobile & Core Web Vitals check
Toggle repeatedly on a mid-range phone: no layout jump (CLS 0), no lag (INP under 200 ms in PageSpeed Insights), and with reduced motion on, the prices should swap instantly.
Building a full site like this?
Skip the manual setup — I ship complete Figma-to-Webflow & Framer builds in 5–10 business days, or can wire this exact interaction into your live site in 48 hours ($250).