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.json

Overview

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

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

PropTypeDescription
min / max / stepnumberdefault — / — / 1The range. On a log scale the step grows with the value (thousands near 10K, ten-thousands near 100K).
value / defaultValue / onValueChangenumberControl it, or let it keep its own.
price / plan(value) => number / (value) => stringYour 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 / currencystring / Intl.NumberFormatOptions / stringdefault — / — / "USD"How the value and price read.
contactFromnumberFrom this value, "Let’s talk" replaces the price.
yearlyDiscountnumbere.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