Pricing calculator
Drag to your size and watch the price roll, the plan morph and, at the top, “Let’s talk”.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/pricing-calculator.jsonOverview
Usage-based pricing is hard to read from a table. Here people drag to their size and the answer comes to them. The thumb follows the finger with the fill and a value bubble riding along, and the price rolls digit by digit as it goes. Click the track or a mark and the thumb is thrown there with a little give. Crossing a tier morphs the plan’s name, lights the mark and changes the button’s words; past the last tier the price folds away and the line becomes "Let’s talk". With a yearly discount, a Monthly/Yearly switch rolls every figure to its yearly price. The formula is yours: pass price(value) and plan(value), on a linear or logarithmic scale.
States
- drag
- The thumb, fill and value bubble track the pointer exactly; the price and the value roll (300ms while dragging).
- jump
- A press on the track, a mark’s label, Page Up/Down, Home or End throws the thumb to the new value over 520ms with a slight overshoot.
- tier
- Crossing a mark morphs the plan name and the button’s words and turns the mark white on the fill.
- contact
- At contactFrom the price folds to nothing through a 6px blur as "Let’s talk" opens in its place and the button turns to an outline "Contact sales".
- yearly
- The switch is thrown to Yearly; the monthly figure rolls down by the discount and the line under it morphs to "$2,995 billed yearly".
Usage
import { PricingCalculator } from "@/components/pricing-calculator";
<PricingCalculator
min={1_000}
max={1_000_000}
scale="log"
unit="monthly active users"
valueFormat={{ notation: "compact" }}
price={(users) => 29 + users * 0.003}
plan={(users) => (users < 10_000 ? "Starter" : "Growth")}
marks={[{ value: 10_000, label: "10K" }]}
contactFrom={500_000}
yearlyDiscount={0.2}
/>Works with
Installs with it
- Number rollNumbers that change like an odometer: every digit rolls in the direction the number moved.
- Text morphText that changes the way it should: shared letters slide over, new ones rise out of a blur, the width eases.
- Pricing toggleMonthly or yearly: the thumb slides with a little give, and every price on the page rolls to its new amount.
In a pricing section
Above your plan cards, it answers "what would I pay?" before anyone reads a table.
import { PricingCalculator } from "@/components/pricing-calculator";
import { price, plan, MARKS } from "@/lib/pricing";
export function Pricing() {
return (
<section className="mx-auto max-w-3xl px-6 py-24">
<h2 className="text-4xl font-medium tracking-[-0.03em]">Pay for what you use</h2>
<div className="mt-10 rounded-[22px] border border-border p-8">
<PricingCalculator
min={1_000}
max={1_000_000}
scale="log"
unit="monthly active users"
valueFormat={{ notation: "compact" }}
price={price}
plan={plan}
marks={MARKS}
contactFrom={500_000}
yearlyDiscount={0.2}
cta={{ label: (p) => `Start with ${p}`, onClick: (users, p, billing) => checkout({ users, plan: p, billing }) }}
/>
</div>
</section>
);
}Props
| Prop | Type | Description |
|---|---|---|
| min / max / step | numberdefault — / — / 1 | The range. On a log scale the step grows with the value (thousands near 10K, ten-thousands near 100K). |
| value / defaultValue / onValueChange | number | Control it, or let it keep its own. |
| price / plan | (value) => number / (value) => string | Your monthly price and plan name for a value. |
| marks | { value; label }[] | Ticks and labels along the track, e.g. where tiers change; clicking a label throws the thumb there. |
| scale | "linear" | "log"default "linear" | Log spaces orders of magnitude evenly, so 1K, 10K and 100K get the same room. |
| unit / valueFormat / currency | string / Intl.NumberFormatOptions / stringdefault — / — / "USD" | How the value and price read. |
| contactFrom | number | From this value, "Let’s talk" replaces the price. |
| yearlyDiscount | number | e.g. 0.2 adds a Monthly/Yearly switch with "Save 20%". |
| cta | { label(plan); contactLabel?; onClick?(value, plan, billing) } | The button under the price; its words follow the plan. |
Accessibility and motion
- The thumb is a real slider: Arrow keys nudge, Page Up/Down move a tenth, Home and End jump to the ends. Its value text reads the size and the price ("25K monthly active users, $96 a month").
- The plan is announced politely as you cross a tier; the rolling digits are hidden from screen readers.
- Pointer events with capture, so dragging works with mouse, pen and touch and keeps working outside the track.
- Installs Number roll, Text morph and Pricing toggle. With reduced motion the thumb and figures move in place.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
The CLI also installs Number roll, Text morph, Pricing toggle.
Source
›Show the full source of pricing-calculator.tsx (356 lines)
"use client";
import * as React from "react";
import { NumberRoll } from "./number-roll";
import { PricingToggle } from "./pricing-toggle";
import { TextMorph } from "./text-morph";
/* ─────────────────────────────────────────────────────────
* PRICING CALCULATOR: drag to your size, watch the price
*
* drag the thumb follows your finger; the fill and a
* value bubble ride with it and the price rolls as
* you go
* jump click the track (or tap a mark) and the thumb is
* thrown there with a little give
* tiers crossing a mark morphs the plan's name and lights
* the mark; the button's words follow
* contact past the last tier the price folds away and the
* line becomes "Let's talk"
* yearly with a discount, a Monthly/Yearly switch rolls every
* figure to its yearly price
*
* Bring your own formula: price(value) and plan(value). A real
* slider: arrow keys, Page Up/Down, Home and End.
* ───────────────────────────────────────────────────────── */
export interface CalculatorMark {
value: number;
label: string;
}
export interface PricingCalculatorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
min: number;
max: number;
step?: number;
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
/** Monthly price for a value, before any yearly discount. */
price: (value: number) => number;
/** The plan a value falls in, e.g. "Growth". */
plan?: (value: number) => string;
/** Ticks along the track, e.g. where tiers change. */
marks?: CalculatorMark[];
/** What's being counted, e.g. "monthly active users". */
unit?: string;
/** Intl.NumberFormat options for the value, e.g. { notation: "compact" }. */
valueFormat?: Intl.NumberFormatOptions;
currency?: string;
/** Evenly spaced orders of magnitude, for ranges like 1,000 to 1,000,000. */
scale?: "linear" | "log";
/** At or above this, show "Let's talk" instead of a price. */
contactFrom?: number;
/** e.g. 0.2 shows a Monthly/Yearly switch with "Save 20%". */
yearlyDiscount?: number;
/** The button under the price; its words follow the plan. */
cta?: { label: (plan: string) => string; contactLabel?: string; onClick?: (value: number, plan: string, billing: "monthly" | "yearly") => void };
label?: string;
}
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const THROW = "cubic-bezier(0.34,1.36,0.64,1)";
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background";
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
export function PricingCalculator({
min,
max,
step = 1,
value: valueProp,
defaultValue,
onValueChange,
price,
plan = () => "",
marks = [],
unit = "",
valueFormat,
currency = "USD",
scale = "linear",
contactFrom,
yearlyDiscount,
cta,
label = "Usage",
className = "",
...props
}: PricingCalculatorProps) {
const reduced = useReducedMotion();
const id = React.useId();
const [own, setOwn] = React.useState(defaultValue ?? min);
const value = valueProp ?? own;
const [billing, setBilling] = React.useState<"monthly" | "yearly">("monthly");
const [dragging, setDragging] = React.useState(false);
const [thrown, setThrown] = React.useState(false);
const trackRef = React.useRef<HTMLDivElement>(null);
const thumbRef = React.useRef<HTMLDivElement>(null);
const pointer = React.useRef<number | null>(null);
// Value ↔ position along the track, linear or by orders of magnitude.
const toRatio = React.useCallback(
(v: number) => {
if (scale === "log" && min > 0) return (Math.log(v) - Math.log(min)) / (Math.log(max) - Math.log(min));
return (v - min) / (max - min);
},
[scale, min, max]
);
const fromRatio = React.useCallback(
(r: number) => {
const c = Math.min(1, Math.max(0, r));
const raw = scale === "log" && min > 0 ? Math.exp(Math.log(min) + c * (Math.log(max) - Math.log(min))) : min + c * (max - min);
// On a log scale, round to a step that suits the size (1,000s near 10k, 10,000s near 100k).
const s = scale === "log" ? Math.max(step, 10 ** Math.max(0, Math.floor(Math.log10(raw)) - 1)) : step;
return Math.min(max, Math.max(min, Math.round(raw / s) * s));
},
[scale, min, max, step]
);
const set = (next: number) => {
const v = Math.min(max, Math.max(min, next));
if (valueProp === undefined) setOwn(v);
onValueChange?.(v);
};
const fromPointer = (clientX: number) => {
const track = trackRef.current;
if (!track) return value;
const r = track.getBoundingClientRect();
return fromRatio((clientX - r.left) / r.width);
};
const onPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
pointer.current = e.pointerId;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
thumbRef.current?.focus({ preventScroll: true });
// A press away from the thumb throws it there; a press on it starts a drag.
const onThumb = (e.target as HTMLElement).closest("[data-thumb]");
setThrown(!onThumb);
setDragging(Boolean(onThumb));
set(fromPointer(e.clientX));
};
const onPointerMove = (e: React.PointerEvent) => {
if (pointer.current !== e.pointerId) return;
if (!dragging) {
setThrown(false);
setDragging(true);
}
set(fromPointer(e.clientX));
};
const onPointerUp = (e: React.PointerEvent) => {
if (pointer.current !== e.pointerId) return;
pointer.current = null;
setDragging(false);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const r = toRatio(value);
const nudge = (d: number) => {
// Each press moves at least one visible step, even where a log scale is coarse.
const next = fromRatio(r + d);
return next === value ? value + Math.sign(d) * step : next;
};
const moves: Record<string, () => number> = {
ArrowRight: () => nudge(0.01),
ArrowUp: () => nudge(0.01),
ArrowLeft: () => nudge(-0.01),
ArrowDown: () => nudge(-0.01),
PageUp: () => nudge(0.1),
PageDown: () => nudge(-0.1),
Home: () => min,
End: () => max,
};
if (!(e.key in moves)) return;
e.preventDefault();
setThrown(e.key === "Home" || e.key === "End" || e.key.startsWith("Page"));
set(moves[e.key]());
};
const ratio = Math.min(1, Math.max(0, toRatio(value)));
const contact = contactFrom !== undefined && value >= contactFrom;
const planName = plan(value);
const discount = billing === "yearly" && yearlyDiscount ? yearlyDiscount : 0;
const monthly = price(value) * (1 - discount);
const currencyFormat = React.useMemo<Intl.NumberFormatOptions>(() => ({ style: "currency", currency, maximumFractionDigits: 0 }), [currency]);
const valueText = new Intl.NumberFormat(undefined, valueFormat).format(value);
const moveTransition = reduced || dragging ? "none" : thrown ? `transform 520ms ${THROW}` : `transform 220ms ${EASE}`;
const fillTransition = reduced || dragging ? "none" : thrown ? `transform 520ms ${THROW}` : `transform 220ms ${EASE}`;
return (
<div className={`w-full ${className}`} {...props}>
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
{/* The plan's name morphs as you cross a tier. */}
<p className="h-5 text-[13px] font-medium text-primary">
<TextMorph>{contact ? "Enterprise" : planName}</TextMorph>
</p>
<div className="mt-1 flex h-[60px] items-baseline">
<span
className="grid"
style={{
gridTemplateColumns: contact ? "0fr" : "1fr",
opacity: contact ? 0 : 1,
filter: contact ? "blur(6px)" : "none",
transition: reduced ? "none" : `grid-template-columns 460ms ${EASE}, opacity 300ms ${EASE}, filter 300ms ${EASE}`,
}}
>
<span className="flex min-w-0 items-baseline gap-1 overflow-hidden whitespace-nowrap">
<NumberRoll value={Math.round(monthly)} format={currencyFormat} className="text-[52px] font-medium leading-none tracking-[-0.04em] text-foreground" />
<span className="text-[15px] text-muted-foreground">/mo</span>
</span>
</span>
<span
className="grid"
style={{
gridTemplateColumns: contact ? "1fr" : "0fr",
opacity: contact ? 1 : 0,
filter: contact ? "none" : "blur(6px)",
transition: reduced ? "none" : `grid-template-columns 460ms ${EASE}, opacity 300ms ${EASE} 120ms, filter 300ms ${EASE} 120ms`,
}}
>
<span className="min-w-0 overflow-hidden whitespace-nowrap text-[52px] font-medium leading-none tracking-[-0.04em] text-foreground">Let’s talk</span>
</span>
</div>
<p className="mt-2 min-h-5 text-[13px] leading-5 text-muted-foreground">
<TextMorph animateWidth={false}>
{contact
? "Volume pricing, SSO and a named contact"
: discount
? `${new Intl.NumberFormat(undefined, currencyFormat).format(Math.round(monthly * 12))} billed yearly`
: "Billed monthly, cancel anytime"}
</TextMorph>
</p>
</div>
{yearlyDiscount ? (
<PricingToggle
value={billing}
onValueChange={(v) => setBilling(v as "monthly" | "yearly")}
options={[
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly", badge: `Save ${Math.round(yearlyDiscount * 100)}%` },
]}
/>
) : null}
</div>
{/* The slider. */}
<div className="mt-10 select-none">
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="relative flex h-10 cursor-pointer touch-none items-center"
>
<div className="absolute inset-x-0 h-1.5 overflow-hidden rounded-full bg-muted shadow-[inset_0_0_0_1px_var(--border)]">
<div
className="h-full origin-left rounded-full bg-primary"
style={{ transform: `scaleX(${ratio})`, transition: fillTransition }}
/>
</div>
{marks.map((m) => {
const r = toRatio(m.value);
const passed = value >= m.value;
return (
<span
key={m.value}
aria-hidden="true"
className="absolute top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-[background-color,transform] duration-300"
style={{ left: `${r * 100}%`, background: passed ? "var(--primary-foreground)" : "color-mix(in oklab, var(--foreground) 22%, transparent)" }}
/>
);
})}
{/* The thumb, with the value riding above it. */}
<div
className="pointer-events-none absolute inset-y-0 left-0 w-full"
style={{ transform: `translateX(${ratio * 100}%)`, transition: moveTransition }}
>
<div
ref={thumbRef}
data-thumb=""
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={`${valueText}${unit ? ` ${unit}` : ""}${contact ? ", contact sales" : `, ${new Intl.NumberFormat(undefined, currencyFormat).format(Math.round(monthly))} a month`}`}
aria-describedby={`${id}-plan`}
onKeyDown={onKeyDown}
className={`pointer-events-auto absolute top-1/2 flex size-6 -translate-x-1/2 -translate-y-1/2 cursor-grab items-center justify-center rounded-full bg-background shadow-[0_0_0_1px_var(--border)] outline-none transition-[scale] duration-200 focus-visible:ring-2 focus-visible:ring-ring/40 ${
dragging ? "scale-110 cursor-grabbing" : ""
}`}
>
<span className="size-2 rounded-full bg-primary" />
{/* Centred over the thumb mid-track, leaning inwards at the ends so it never leaves the track. */}
<span
aria-hidden="true"
className="absolute bottom-full left-1/2 mb-2.5 flex h-7 items-baseline whitespace-nowrap rounded-full bg-background px-2.5 pt-[5px] text-[12.5px] font-medium tabular-nums text-foreground shadow-[inset_0_0_0_1px_var(--border)]"
style={{ transform: `translateX(${-ratio * 100}%)`, transition: moveTransition }}
>
<NumberRoll value={value} format={valueFormat} duration={dragging ? 300 : 600} />
{unit && <span className="ml-1 font-normal text-muted-foreground">{unit.split(" ").slice(-1)[0]}</span>}
</span>
</div>
</div>
</div>
{marks.length > 0 && (
<div className="relative mt-1 h-5 text-[11.5px]">
{marks.map((m) => (
<button
key={m.value}
type="button"
tabIndex={-1}
onClick={() => {
setThrown(true);
set(m.value);
}}
className={`absolute -translate-x-1/2 whitespace-nowrap transition-colors duration-300 hover:text-foreground ${value >= m.value ? "text-foreground" : "text-muted-foreground"}`}
style={{ left: `${toRatio(m.value) * 100}%` }}
>
{m.label}
</button>
))}
</div>
)}
</div>
{cta && (
<div className="mt-8">
<button
type="button"
onClick={() => cta.onClick?.(value, contact ? "Enterprise" : planName, billing)}
className={`inline-flex h-10 items-center justify-center rounded-full px-5 text-[13.5px] font-medium transition-[background-color,color,box-shadow,transform] duration-300 active:scale-[0.97] ${FOCUS} ${
contact ? "text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent" : "bg-primary text-primary-foreground hover:bg-primary/90"
}`}
>
<TextMorph>{contact ? (cta.contactLabel ?? "Contact sales") : cta.label(planName)}</TextMorph>
</button>
</div>
)}
<p id={`${id}-plan`} className="sr-only" aria-live="polite">
{contact ? "Contact sales for this size" : `${planName} plan`}
</p>
</div>
);
}
Registry item: /r/pricing-calculator.json