How to build a usage-based pricing calculator in React
A usage-based pricing calculator in React stores each slider value in a state array, computes the total with useMemo (basePrice + sum of value * pricePerUnit), and feeds the result into a Framer Motion counter that animates the number on every change using useMotionValue and the animate() imperative API.
- Stack: React 18 + Framer Motion 11 + Tailwind v4 + lucide-react, ~163 lines total.
- Price animation: useMotionValue + animate() with a spring-like ease [0.16, 1, 0.3, 1] over 500ms.
- Sliders are native <input type='range'> elements styled with a CSS linear-gradient that tracks the fill percentage dynamically.
- Accessible: native range inputs are keyboard-navigable and screen-reader-compatible out of the box.
- Supports EUR and USD; sliders, features list and CTA label are fully configurable via props.
Pricing Calculator is a React section where users drag sliders to define their usage (seats, storage, API calls, etc.) and watch the monthly price update instantly, driven by an animated counter. It replaces static tiered pricing tables with a direct, tactile experience that shows each plan's cost before the user even reaches the CTA.
Anatomy
The section is a centered max-w-4xl column. A fade-in header holds the badge (Calculator icon + label), the h2, and an optional subtitle. Below it, a rounded card splits into two columns on md+ screens: the left column stacks the configurable sliders (label, current value, native range input, min/max hints); the right column, separated by a vertical border, shows the animated price and the billing period. A horizontal divider at the bottom of the card surfaces the included-features list as inline chips with Check icons, and a full-width pill button closes the card.
How it works
The heart of the component is a two-part reactivity chain. First, slider onChange handlers update a flat number[] state; useMemo recalculates the total each time that state changes, so the formula stays outside the render cycle. Second, an AnimatedPrice sub-component receives the computed value as a prop and feeds it to a Framer Motion useMotionValue. Whenever the prop changes, a useEffect fires animate(mv, value, { duration: 0.5, ease }) which drives the motion value from its current number to the new one. A second useEffect subscribes to display.on('change') and writes the rounded integer directly into a span's textContent, bypassing React's reconciler entirely for a silky-smooth counter animation with no re-renders.
How to build it in React
Model sliders as config objects
Define a SliderConfig interface (label, min, max, step, defaultValue, pricePerUnit, unit) and accept a sliders prop. Initialize state from the defaults so adding or removing a slider never requires touching component logic.
const [values, setValues] = useState<number[]>( () => sliders.map((s) => s.defaultValue) );Derive the total with useMemo
Compute the price outside the JSX with useMemo so the formula re-runs only when values or the slider configs change. This keeps the render function clean and makes the cost formula easy to unit-test.
const totalPrice = useMemo(() => { return basePrice + sliders.reduce( (sum, s, i) => sum + (values[i] ?? s.defaultValue) * s.pricePerUnit, 0 ); }, [basePrice, sliders, values]);Animate the price with a motion value counter
Create AnimatedPrice as a standalone sub-component. It holds a useMotionValue(0) and a derived display = useTransform(mv, Math.round). When the value prop changes, call Framer Motion's imperative animate(mv, value, { duration: 0.5, ease }) and write the result straight into a span ref, no state, no re-render.
useEffect(() => { const controls = animate(mv, value, { duration: 0.5, ease: [0.16, 1, 0.3, 1], }); return controls.stop; }, [value, mv]);Style the range fill with a dynamic gradient
Native range inputs hide their fill in most browsers. Pass a computed inline style with a linear-gradient that transitions from the accent color to the border color at exactly the slider's fill percentage. Derive that percentage from (value - min) / (max - min) * 100 and template it into the background property.
style={{ background: `linear-gradient(to right, var(--color-accent) ${pct}%, var(--color-border) ${pct}%)`, }}
When to use it
Reach for it on any SaaS landing page where pricing scales with usage: seats, API calls, storage, bandwidth. It removes the guesswork from tier-based tables and helps users self-qualify before hitting the sales team. Skip it for simple flat-rate or freemium products where a classic 2-column pricing card is faster to scan. On touch devices the component works fine with tap-drag on the sliders, but test on iOS Safari where range styling differs.
Used by
- Vercel, Uses an interactive slider to let users estimate compute and bandwidth costs before picking a plan.
- Twilio, Entire pricing section is a pay-as-you-go calculator; users input volumes and see a live cost estimate.
- Cloudflare, R2 and Workers pricing pages feature range-based estimators that update cost totals as you adjust request counts.
- Lemon Squeezy, Revenue slider estimates the platform fee dynamically, showing the exact cut before signup.
FAQ
Why use useMemo for the price calculation instead of computing it inside useEffect?
useMemo derives the price synchronously during render so the AnimatedPrice sub-component always receives the correct value in the same paint. useEffect runs after render, which would cause a one-frame lag where the old price triggers the animation before the new value arrives.
How does the animated counter avoid React re-renders on every frame?
AnimatedPrice writes the rounded integer directly into a span's textContent via a display.on('change') subscription, bypassing React state entirely. The DOM node updates at 60fps without triggering the reconciler.
Can I add a billing toggle (monthly/annual) on top of the sliders?
Yes. Add a billingCycle state ('monthly' | 'annual') and include it in the useMemo dependency array. Multiply totalPrice by 0.8 (for example) when annual is selected; the AnimatedPrice counter will animate the transition automatically.
Is it accessible without a visible label for each slider value?
The current value is displayed in a visible span next to the slider label, which satisfies the visual requirement. For screen reader users, add aria-label and aria-valuetext to each <input type='range'> with the formatted value (e.g. '5 users') so the value is announced on change.