Skip to content
Marketing block

Tiered Usage Calculator

A slider that turns a rate card into the number a buyer actually cares about, with a per-tier breakdown so the total can be checked rather than taken on trust.

224 linesNo dependenciesAdded 24 Aug 2026Updated 13 Sept 2026
  • pricing
  • usage
  • calculator
  • slider
  • metered

What's included

  • components/pricing-usage-calculator.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add pricing-usage-calculator

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

What it costs at your volume

Move the slider. The first 10,000 are free every month.

1K1M
First 10K free
—
Estimated monthly total$0.00
Start free

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/pricing-usage-calculator.tsx
'use client'

/**
 * <PricingUsageCalculator> — a slider that answers "what will this cost me".
 *
 * Usage pricing has a specific failure: the page states a rate, the visitor
 * has to do arithmetic to find their own number, and most of them do not —
 * they guess high and leave. Neither <PricingTiers> nor <ComparisonTable>
 * helps, because both assume the price is a property of the plan rather
 * than of the buyer.
 *
 * So the interaction is the product here. Move the slider, read your bill.
 *
 * Tiered rather than flat, because that is how these are actually sold and
 * a flat rate needs no calculator at all — the whole reason someone cannot
 * work it out in their head is that the marginal price changes. The
 * breakdown under the total shows each tier's contribution, so the number
 * is checkable rather than asserted; a total a buyer cannot reproduce is a
 * total they do not trust.
 *
 * A native <input type="range"> rather than a drawn track. It arrives with
 * keyboard support, a real focus ring, arrow and Home/End handling, and the
 * platform's own touch target, all of which a div with a pointer listener
 * has to reimplement and usually gets wrong for the keyboard. The visible
 * total is tied to it with `aria-describedby`, so the value and its
 * consequence are announced together rather than as an orphaned number.
 *
 * Steps are indices into a fixed scale, not raw units. A linear slider from
 * 1k to 10M spends most of its travel in a range nobody picks; the scale
 * gives every stop a usable resolution.
 */

import * as React from 'react'

export interface UsageTier {
  /** Units up to this bound priced at `pricePerUnitCents`. Infinity for the last. */
  upTo: number
  pricePerUnitCents: number
}

export interface PricingUsageCalculatorProps {
  heading?: string
  subheading?: string
  /** Selectable volumes, ascending. The slider steps between them. */
  scale?: number[]
  tiers?: UsageTier[]
  unitLabel?: string
  /** Charged before any usage. */
  baseCents?: number
  baseLabel?: string
  ctaLabel?: string
  ctaHref?: string
  className?: string
}

const DEFAULT_SCALE = [
  1_000, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000,
]

const DEFAULT_TIERS: UsageTier[] = [
  { upTo: 10_000, pricePerUnitCents: 0 },
  { upTo: 100_000, pricePerUnitCents: 0.05 },
  { upTo: Infinity, pricePerUnitCents: 0.02 },
]

const money = (cents: number) =>
  (cents / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' })

const compact = (n: number) =>
  n.toLocaleString('en-US', { notation: 'compact', maximumFractionDigits: 1 })

/**
 * A unit rate, quoted at a scale where it is a legible number.
 *
 * Metered pricing is routinely fractions of a cent per unit, and running
 * that through `money()` rounds it to $0.00 — a breakdown row reading
 * "100K-inf at $0.00" beside a charge of $180 discredits the total it was
 * added to explain. So a sub-cent rate is quoted per thousand, which is how
 * these are sold anyway, and only a rate that survives cent precision is
 * shown per unit.
 */
const rate = (centsPerUnit: number) =>
  centsPerUnit >= 1
    ? `${money(centsPerUnit)} each`
    : `${money(centsPerUnit * 1000)} per 1K`

/** Per-tier contribution for a volume, in the order the tiers were given. */
function breakdown(units: number, tiers: UsageTier[]) {
  const rows: { label: string; units: number; cents: number }[] = []
  let floor = 0
  for (const tier of tiers) {
    if (units <= floor) break
    const inTier = Math.min(units, tier.upTo) - floor
    rows.push({
      label:
        tier.pricePerUnitCents === 0
          ? `First ${compact(tier.upTo)} free`
          : `${compact(floor)}–${tier.upTo === Infinity ? '∞' : compact(tier.upTo)} at ${rate(tier.pricePerUnitCents)}`,
      units: inTier,
      cents: inTier * tier.pricePerUnitCents,
    })
    floor = tier.upTo
  }
  return rows
}

export function PricingUsageCalculator({
  heading = 'What it costs at your volume',
  subheading = 'Move the slider. The first 10,000 are free every month.',
  scale = DEFAULT_SCALE,
  tiers = DEFAULT_TIERS,
  unitLabel = 'requests / month',
  baseCents = 0,
  baseLabel = 'Platform fee',
  ctaLabel = 'Start free',
  ctaHref = '/signup',
  className = '',
}: PricingUsageCalculatorProps) {
  /*
    Ids come from `useId`, not from a literal.

    A literal is correct until the section appears twice on one page — and
    then `aria-labelledby` resolves to whichever copy is first in the
    document, so the second one is announced with the first one's heading.
    It renders identically and reads wrongly, which is why nothing catches
    it but an id check.
  */
  const volumeId = React.useId()
  const totalId = React.useId()

  // The slider's value is the index. Two steps of the same width can be an
  // order of magnitude apart in units, which is the point.
  const [index, setIndex] = React.useState(() => Math.min(2, scale.length - 1))
  const units = scale[index] ?? scale[0]

  const rows = React.useMemo(() => breakdown(units, tiers), [units, tiers])
  const usageCents = rows.reduce((sum, r) => sum + r.cents, 0)
  const totalCents = usageCents + baseCents

  return (
    <section
      className={`mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 sm:py-24 ${className}`}
    >
      <div className="mx-auto mb-10 max-w-2xl text-center">
        <h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">
          {heading}
        </h2>
        {subheading ? (
          <p className="mt-3 text-muted-foreground">{subheading}</p>
        ) : null}
      </div>

      <div className="rounded-3xl border border-border/60 bg-card/60 p-6 sm:p-8">
        <label
          htmlFor={volumeId}
          className="flex flex-wrap items-baseline justify-between gap-2"
        >
          <span className="text-sm font-medium text-muted-foreground">
            Monthly volume
          </span>
          <span className="text-2xl font-bold tabular-nums">
            {units.toLocaleString('en-US')}{' '}
            <span className="text-sm font-normal text-muted-foreground">
              {unitLabel}
            </span>
          </span>
        </label>

        <input
          id={volumeId}
          type="range"
          min={0}
          max={scale.length - 1}
          step={1}
          value={index}
          onChange={(e) => setIndex(Number(e.target.value))}
          aria-describedby={totalId}
          // The thumb and track come from the platform; `accent-color` is
          // what tints them without replacing the control.
          className="mt-4 w-full accent-primary"
        />
        <div className="mt-1 flex justify-between text-xs text-muted-foreground">
          <span>{compact(scale[0])}</span>
          <span>{compact(scale[scale.length - 1])}</span>
        </div>

        <dl className="mt-8 space-y-2 border-t border-border/60 pt-6 text-sm">
          {baseCents > 0 ? (
            <div className="flex justify-between gap-4">
              <dt className="text-muted-foreground">{baseLabel}</dt>
              <dd className="tabular-nums">{money(baseCents)}</dd>
            </div>
          ) : null}
          {rows.map((r) => (
            <div key={r.label} className="flex justify-between gap-4">
              <dt className="text-muted-foreground">{r.label}</dt>
              <dd className="tabular-nums">
                {r.cents === 0 ? '—' : money(r.cents)}
              </dd>
            </div>
          ))}
        </dl>

        <div
          id={totalId}
          className="mt-6 flex items-baseline justify-between gap-4 border-t border-border/60 pt-6"
        >
          <span className="font-semibold">Estimated monthly total</span>
          <span className="text-3xl font-extrabold tracking-tight tabular-nums">
            {money(totalCents)}
          </span>
        </div>

        <a
          href={ctaHref}
          className="mt-6 inline-flex w-full items-center justify-center rounded-xl bg-primary px-5 py-3 font-semibold text-primary-foreground transition-opacity hover:opacity-90"
        >
          {ctaLabel}
        </a>
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Nothing to install. No component library, no icon package.
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/pricing-usage-calculator.tsx and import it where you need the section:

import { PricingUsageCalculator } from '@/components/pricing-usage-calculator'

Customize

7 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
headingstring'What it costs at your volume'
subheadingstring'Move the slider. The first 10,000 are free e…
scaleSelectable volumes, ascending. The slider steps between them.number[]DEFAULT_SCALE
tiersUsageTier[]DEFAULT_TIERS
unitLabelstring'requests / month'
baseCentsCharged before any usage.number0
baseLabelstring'Platform fee'
ctaLabelstring'Start free'
ctaHrefstring'/signup'
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

pricing-usage-calculator.html
<!--
  Tiered Usage Calculator — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 sm:py-24 ">
  <div class="mx-auto mb-10 max-w-2xl text-center">
    <h2 class="text-balance text-3xl font-bold tracking-tight sm:text-4xl">What it costs at your volume</h2>
    <p class="mt-3 text-muted-foreground">Move the slider. The first 10,000 are free every month.</p>
  </div>
  <div class="rounded-3xl border border-border/60 bg-card/60 p-6 sm:p-8">
    <label for="_R_0_" class="flex flex-wrap items-baseline justify-between gap-2">
      <span class="text-sm font-medium text-muted-foreground">Monthly volume</span>
      <span class="text-2xl font-bold tabular-nums">10,000 <span class="text-sm font-normal text-muted-foreground">requests / month</span></span>
    </label>
    <input id="_R_0_" type="range" min="0" max="8" step="1" aria-describedby="_R_0H1_" class="mt-4 w-full accent-primary" value="2" />
    <div class="mt-1 flex justify-between text-xs text-muted-foreground">
      <span>1K</span>
      <span>1M</span>
    </div>
    <dl class="mt-8 space-y-2 border-t border-border/60 pt-6 text-sm">
      <div class="flex justify-between gap-4">
        <dt class="text-muted-foreground">First 10K free</dt>
        <dd class="tabular-nums">—</dd>
      </div>
    </dl>
    <div id="_R_0H1_" class="mt-6 flex items-baseline justify-between gap-4 border-t border-border/60 pt-6">
      <span class="font-semibold">Estimated monthly total</span>
      <span class="text-3xl font-extrabold tracking-tight tabular-nums">$0.00</span>
    </div>
    <a href="/signup" class="mt-6 inline-flex w-full items-center justify-center rounded-xl bg-primary px-5 py-3 font-semibold text-primary-foreground transition-opacity hover:opacity-90">Start free</a>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Pricing

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Pricing

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Pricing blocks

View category
Open the full page for this block

Three-Plan Pricing Toggle

Three plans with a monthly/yearly switch, yearly prices derived from the monthly figure so the two can never disagree.

Pricing192 lines1 dep
Open the full page for this block

Single-Plan Price Card

One price given a whole card, with the objection a one-time price raises answered under the button rather than in a FAQ further down.

Pricing145 lines1 dep
Open the full page for this block

Plan Comparison Matrix

A real table with scoped headers, a sticky first column and screen-reader text behind every check and dash.

Pricing145 lines1 dep
Open the full page for this block

Prepaid Credit Packs

One-time packs with the per-credit price worked out for the reader and the saving stated rather than implied — the pricing shape for a product that sells quantity, not a subscription.

Pricing218 lines1 dep
Open the full page for this block

In-App Plan Picker

A native radiogroup of plans with the current one disabled and the prorated charge announced as it changes — the upgrade dialog, not the marketing table.

Pricing242 lines1 dep
Open the full page for this block

Pricing Value Split

The case for the price, made beside the price — what the alternative costs, in the units a buyer already measures.

Pricing158 linesNo deps
Open the full page for this block

Plan Limits List

What actually runs out on each plan, including what happens when it does — the row a pricing table leaves off.

Pricing149 linesNo deps