How to build a pricing toggle with spring animation in React
A React pricing toggle with animated prices uses Framer Motion's useSpring to drive a pill-shaped toggle, and AnimatePresence in popLayout mode to crossfade the price number vertically when billing period switches. The whole interaction runs on CSS custom properties, so it adapts to any theme token.
- Stack: React 19 + Framer Motion 11 + lucide-react, ~130 lines, zero extra dependencies.
- Core APIs: useSpring, useTransform, AnimatePresence (popLayout mode), motion.div whileHover/whileTap.
- Toggle has aria-pressed and aria-label for keyboard and screen-reader accessibility.
- Responsive grid with auto-fit columns collapses to a single column on narrow screens.
- Colors are fully driven by CSS custom properties (--color-background, --color-accent, etc.), compatible with all 7 theme presets.
This pricing section pairs a spring-physics billing toggle with a price number that morphs between monthly and yearly values through a vertical crossfade. Instead of an abrupt number swap, the old price slides out upward while the new one enters from below. A discount badge pops into view on the yearly label, animated with a spring scale bounce. The whole section is theme-agnostic, driven entirely by CSS custom properties.
Anatomy
The section has three stacked zones: a centered header (eyebrow, h2, subtitle) that fades in on scroll; a billing toggle row with a SpringToggle pill and an AnimatePresence discount badge; and a CSS grid of tier cards. Each card holds a name, description, an AnimatedPrice block, a billing label, a features list with Check icons, and a CTA button. The highlighted tier renders with an inverted color scheme, foreground background, and a subtle scale(1.02) lift.
How it works
The SpringToggle component creates a Framer Motion spring (stiffness 500, damping 35) that maps 0/1 to a pixel x-offset for the thumb via useTransform. Calling spring.set(yearly ? 1 : 0) on each render drives the thumb position without needing any useEffect. The AnimatedPrice block wraps the price number in AnimatePresence with mode='popLayout': each unique price value gets its own motion.span keyed by price, entering from y:24 and exiting to y:-24 with a 300ms spring easing. The discount badge uses a scale spring that bounces through [0, 1.15, 1] on enter. Cards animate in with a staggered whileInView delay (i * 0.08s).
How to build it in React
Build the spring toggle
Create a SpringToggle component that takes a boolean and a callback. Instantiate a Framer Motion spring and map its 0/1 range to the pixel offset where the thumb should sit. Set spring.set() directly in the render body so the thumb always tracks the prop.
const spring = useSpring(yearly ? 1 : 0, { stiffness: 500, damping: 35 }); const x = useTransform(spring, [0, 1], [3, 27]); spring.set(yearly ? 1 : 0); // drives the thumb on every renderAnimate the price with AnimatePresence popLayout
Wrap the price span in AnimatePresence with mode='popLayout' so exiting elements are removed from layout flow before entering ones position. Key each motion.span by the price value so React treats monthly and yearly as distinct elements and triggers the enter/exit animations.
<AnimatePresence mode="popLayout"> <motion.span key={price} initial={{ y: 24, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -24, opacity: 0 }} transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }} style={{ display: "block", fontSize: "3rem", fontWeight: 800 }} > {price} </motion.span> </AnimatePresence>Pop the discount badge with a scale bounce
Render the discount label inside AnimatePresence so it mounts and unmounts with yearly state. Use an animate array for scale, [0, 1.15, 1], to produce a natural overshoot without a separate spring config.
<AnimatePresence> {yearly && ( <motion.span key="badge" initial={{ scale: 0, opacity: 0 }} animate={{ scale: [0, 1.15, 1], opacity: 1 }} exit={{ scale: 0, opacity: 0 }} transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }} > Save 20% </motion.span> )} </AnimatePresence>Build the tier cards with staggered entry
Map over the tiers array and render each card as a motion.div with whileInView. Multiply the index by 0.08 for the delay so cards cascade in left to right. Add layout prop so Framer Motion handles any layout shifts during price animation.
<motion.div key={tier.name} layout initial={{ opacity: 0, y: 32 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.55, ease: E, delay: i * 0.08 }} >
When to use it
This pattern is the right call for SaaS products that offer monthly and yearly billing and need to make the saving tangible without a page reload. The animated price flip draws the eye to the value difference; the spring toggle feels premium without being distracting. Skip it on e-commerce product pages or single-tier services where a billing toggle would be irrelevant. On mobile, the spring thumb and price animation both hold up well, but test the grid collapse on narrow viewports to verify readability.
Used by
- Linear, Uses a monthly/yearly toggle with an instant price update and a visible savings label on the annual plan.
- Vercel, Billing period switch that updates plan prices inline, with clear annual discount labeling across tiers.
- Lemon Squeezy, Animated toggle between monthly and yearly, discount badge on the yearly label, matching this exact pattern.
- Supabase, Monthly/yearly switch that visibly updates prices per tier and highlights the savings on the annual plan.
FAQ
Why use AnimatePresence popLayout instead of just transitioning opacity?
popLayout removes the exiting element from the layout flow immediately, so the entering price can take its correct position without overlap. A plain opacity cross-fade would show both numbers stacked on top of each other during the transition.
How do I add a fourth tier without breaking the grid?
The grid uses repeat(auto-fit, minmax(280px, 1fr)), so a fourth tier will flow naturally into the same row on wide screens or wrap to a second row on narrower ones. Increase the maxWidth constraint on the grid container if you want all four cards side by side.
Can I drive the toggle with a URL param so yearly billing persists on navigation?
Yes. Replace the useState with a value read from searchParams and update the URL on toggle instead of local state. The animation still fires because the spring drives off the prop value, not internal state.
The price spring lags on low-end Android devices. How do I fix that?
The AnimatedPrice component uses only opacity and transform (y), which are GPU-composited and should not trigger layout. If you still see jank, wrap the price block in a will-change: transform container or reduce the stagger delay on the cards to cut total animation time.