Skip to content
Product block

Seat Count & Proration

Changing seats with the bill shown first: the part-period charge and the new recurring amount as two numbers, the arithmetic spelled out, and the asymmetry stated where it bites — removing a seat refunds nothing today.

254 lineslucide-reactAdded 27 Aug 2026
  • billing
  • seats
  • proration
  • subscription
  • per-seat

What's included

  • components/billing-seat-manager.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add billing-seat-manager

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

Seats

£12.00 per seat per month · renews 14 September

9 of 12 seats are in use

2 extra seats for the 18 days left in this period
£14.40
From 14 September, every month
£168.00

The part-period charge is £12.00 × 2 × 18/30 days. It goes on the card ending 4242 today.

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

Source

components/billing-seat-manager.tsx
'use client'

/**
 * <BillingSeatManager> — changing the seat count, with the bill first.
 *
 * Billing & Usage had the plan summary, invoice history, payment methods,
 * the cancel flow and the overage notice. Four of those describe money
 * that has already been decided. The one moment a customer *changes* what
 * they owe — adding or removing seats — had no surface, and it is the one
 * where a surprise costs the most trust.
 *
 * THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
 *
 * The prorated amount is computed and shown *before* the button is
 * pressed, broken into the two numbers it is made of: the part-period
 * charge for the days remaining, and the new recurring amount from the
 * next renewal. Almost every seat picker shows one blended figure, or
 * nothing at all until the receipt arrives — and a customer who expected
 * £120 and was charged £47.35 assumes an error rather than proration.
 *
 * REMOVING A SEAT IS NOT A REFUND, AND SAYS SO
 *
 * The asymmetry is the single most misunderstood thing in per-seat
 * billing. Adding charges immediately; removing takes effect at renewal
 * and returns nothing now. Almost every product buries this in a help
 * article. It is stated here, at the moment the number goes down, because
 * that is when it is worth knowing.
 *
 * YOU CANNOT GO BELOW THE PEOPLE YOU HAVE
 *
 * The minimum is the number of occupied seats, and the control says whose
 * they are rather than just refusing to decrement. A stepper that stops
 * with no explanation reads as broken.
 *
 * ACCESSIBILITY: the stepper is two buttons around a real number input,
 * so it is typeable and not only clickable; the cost preview is
 * `aria-live="polite"` because it changes as a result of pressing a
 * control somewhere else; every currency figure is text.
 */

import * as React from 'react'
import { AlertCircle, Minus, Plus, Users } from 'lucide-react'

export interface BillingSeatManagerProps {
  /** Seats currently paid for. */
  purchased?: number
  /** Seats with a person in them — the floor for any decrease. */
  occupied?: number
  /** Price per seat per period, in the smallest currency unit. */
  pricePerSeat?: number
  currency?: string
  /** Days left in the current billing period, for the proration maths. */
  daysRemaining?: number
  periodDays?: number
  renewsOn?: string
  className?: string
}

/** Pence/cents → "£12.00". Formatting money by hand is how rounding drifts. */
function money(minorUnits: number, currency: string): string {
  return new Intl.NumberFormat('en-GB', {
    style: 'currency',
    currency,
  }).format(minorUnits / 100)
}

export function BillingSeatManager({
  purchased = 12,
  occupied = 9,
  pricePerSeat = 1200,
  currency = 'GBP',
  daysRemaining = 18,
  periodDays = 30,
  renewsOn = '14 September',
  className = '',
}: BillingSeatManagerProps) {
  /*
    Per-instance prefix for every id this block emits.

    The literals these replaced were a latent duplicate the moment the
    block appeared twice on one document, and `aria-labelledby` on a
    duplicated id resolves to the first match -- so the second copy was
    labelled by the first copy's heading. Client component, so `useId` is
    the right tool.
  */
  const uid = React.useId()

  /*
   * Opens two seats above what is paid for.
   *
   * At parity the card says "nothing has changed", which is true and
   * useless: the proration breakdown is the component, and it does not
   * exist until the number moves. Press Cancel to see the resting state.
   */
  const [seats, setSeats] = React.useState(purchased + 2)

  const delta = seats - purchased
  const adding = delta > 0
  const removing = delta < 0

  /*
    Proration, in the open. A part-period charge is the per-seat price
    scaled by the fraction of the period left — which is exactly the
    number a customer cannot reconstruct from a receipt, and exactly the
    one they try to.
  */
  const fraction = periodDays === 0 ? 0 : daysRemaining / periodDays
  const dueNow = adding ? Math.round(delta * pricePerSeat * fraction) : 0
  const nextRecurring = seats * pricePerSeat

  return (
    <section className={`mx-auto w-full max-w-2xl px-4 py-16 sm:px-6 ${className}`}>
      <div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
        <header className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
          <h2 className="text-base font-semibold text-foreground">Seats</h2>
          <p className="text-xs text-muted-foreground">
            {money(pricePerSeat, currency)} per seat per month · renews {renewsOn}
          </p>
        </header>

        <div className="mt-5 flex flex-wrap items-center gap-4">
          <div className="flex items-center gap-2">
            <button
              type="button"
              onClick={() => setSeats((s) => Math.max(occupied, s - 1))}
              disabled={seats <= occupied}
              aria-label="Remove a seat"
              className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-background text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              <Minus aria-hidden className="h-4 w-4" />
            </button>

            {/* A real input: seat counts get typed, not clicked, past ten. */}
            <label htmlFor={`${uid}-seat-count`} className="sr-only">
              Number of seats
            </label>
            <input
              id={`${uid}-seat-count`}
              type="number"
              inputMode="numeric"
              min={occupied}
              max={999}
              value={seats}
              onChange={(e) => {
                const next = Number.parseInt(e.target.value, 10)
                setSeats(Number.isNaN(next) ? occupied : Math.max(occupied, Math.min(999, next)))
              }}
              className="h-9 w-20 rounded-lg border border-field bg-background px-3 text-center text-sm font-semibold tabular-nums text-foreground outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
            />

            <button
              type="button"
              onClick={() => setSeats((s) => Math.min(999, s + 1))}
              aria-label="Add a seat"
              className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-background text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              <Plus aria-hidden className="h-4 w-4" />
            </button>
          </div>

          <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
            <Users aria-hidden className="h-3.5 w-3.5" />
            {occupied} of {purchased} seats are in use
          </p>
        </div>

        {/* Why the minus button stops, said rather than implied. */}
        {seats <= occupied ? (
          <p className="mt-2 text-xs text-muted-foreground">
            {occupied} seats are occupied. Remove someone from the team to go below{' '}
            {occupied}.
          </p>
        ) : null}

        {/*
          The preview. Live, because it is the consequence of a control
          elsewhere on the card and it is the whole reason this screen
          exists.
        */}
        <div
          aria-live="polite"
          className="mt-5 rounded-xl border border-border bg-muted/40 p-4"
        >
          {delta === 0 ? (
            <p className="text-sm text-muted-foreground">
              Nothing has changed. You keep {purchased} seats at{' '}
              {money(nextRecurring, currency)} a month.
            </p>
          ) : adding ? (
            <dl className="space-y-2 text-sm">
              <div className="flex items-baseline justify-between gap-3">
                <dt className="text-muted-foreground">
                  {delta} extra {delta === 1 ? 'seat' : 'seats'} for the {daysRemaining} days
                  left in this period
                </dt>
                <dd className="shrink-0 font-semibold tabular-nums text-foreground">
                  {money(dueNow, currency)}
                </dd>
              </div>
              <div className="flex items-baseline justify-between gap-3 border-t border-border pt-2">
                <dt className="text-muted-foreground">
                  From {renewsOn}, every month
                </dt>
                <dd className="shrink-0 font-semibold tabular-nums text-foreground">
                  {money(nextRecurring, currency)}
                </dd>
              </div>
              <p className="pt-1 text-xs text-muted-foreground">
                The part-period charge is {money(pricePerSeat, currency)} × {delta} ×{' '}
                {daysRemaining}/{periodDays} days. It goes on the card ending 4242 today.
              </p>
            </dl>
          ) : (
            <div className="space-y-2 text-sm">
              <p className="text-foreground">
                Removing {Math.abs(delta)} {Math.abs(delta) === 1 ? 'seat' : 'seats'} takes
                effect on {renewsOn}.
              </p>
              {/* The asymmetry, at the moment it matters. */}
              <p className="inline-flex items-start gap-1.5 rounded-md bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-400">
                <AlertCircle aria-hidden className="mt-0.5 h-3 w-3 shrink-0" />
                Nothing is refunded now. The seats stay usable until {renewsOn}, and the
                bill drops to {money(nextRecurring, currency)} from then on.
              </p>
            </div>
          )}
        </div>

        <div className="mt-4 flex flex-wrap gap-2">
          <button
            type="button"
            disabled={delta === 0}
            className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            {adding
              ? `Add ${delta} and pay ${money(dueNow, currency)}`
              : removing
                ? `Remove ${Math.abs(delta)} at renewal`
                : 'Update seats'}
          </button>
          <button
            type="button"
            disabled={delta === 0}
            onClick={() => setSeats(purchased)}
            className="inline-flex h-9 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            Cancel
          </button>
        </div>
      </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.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/billing-seat-manager.tsx and import it where you need the section:

import { BillingSeatManager } from '@/components/billing-seat-manager'

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
purchasedSeats currently paid for.number12
occupiedSeats with a person in them — the floor for any decrease.number9
pricePerSeatPrice per seat per period, in the smallest currency unit.number1200
currencystring'GBP'
daysRemainingDays left in the current billing period, for the proration maths.number18
periodDaysnumber30
renewsOnstring'14 September'
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.

billing-seat-manager.html
<!--
  Seat Count & Proration — 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-2xl px-4 py-16 sm:px-6 ">
  <div class="rounded-2xl border border-border bg-card p-5 sm:p-6">
    <header class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
      <h2 class="text-base font-semibold text-foreground">Seats</h2>
      <p class="text-xs text-muted-foreground">£12.00 per seat per month · renews 14 September</p>
    </header>
    <div class="mt-5 flex flex-wrap items-center gap-4">
      <div class="flex items-center gap-2">
        <button type="button" aria-label="Remove a seat" class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-background text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus h-4 w-4" aria-hidden="true">
            <path d="M5 12h14"></path>
          </svg>
        </button>
        <label for="_R_0_-seat-count" class="sr-only">Number of seats</label>
        <input id="_R_0_-seat-count" type="number" inputMode="numeric" min="9" max="999" class="h-9 w-20 rounded-lg border border-field bg-background px-3 text-center text-sm font-semibold tabular-nums text-foreground outline-none transition focus-visible:ring-2 focus-visible:ring-ring" value="14" />
        <button type="button" aria-label="Add a seat" class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-background text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus h-4 w-4" aria-hidden="true">
            <path d="M5 12h14"></path>
            <path d="M12 5v14"></path>
          </svg>
        </button>
      </div>
      <p class="inline-flex items-center gap-1.5 text-xs text-muted-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users h-3.5 w-3.5" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><path d="M16 3.128a4 4 0 0 1 0 7.744"></path><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><circle cx="9" cy="7" r="4"></circle></svg>9 of 12 seats are in use</p>
    </div>
    <div aria-live="polite" class="mt-5 rounded-xl border border-border bg-muted/40 p-4">
      <dl class="space-y-2 text-sm">
        <div class="flex items-baseline justify-between gap-3">
          <dt class="text-muted-foreground">2 extra seats for the 18 days left in this period</dt>
          <dd class="shrink-0 font-semibold tabular-nums text-foreground">£14.40</dd>
        </div>
        <div class="flex items-baseline justify-between gap-3 border-t border-border pt-2">
          <dt class="text-muted-foreground">From 14 September, every month</dt>
          <dd class="shrink-0 font-semibold tabular-nums text-foreground">£168.00</dd>
        </div>
        <p class="pt-1 text-xs text-muted-foreground">The part-period charge is £12.00 × 2 × 18/30 days. It goes on the card ending 4242 today.</p>
      </dl>
    </div>
    <div class="mt-4 flex flex-wrap gap-2">
      <button type="button" class="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Add 2 and pay £14.40</button>
      <button type="button" class="inline-flex h-9 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">Cancel</button>
    </div>
  </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 Billing & Usage

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 Billing & Usage

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 Billing & Usage blocks

View category
Open the full page for this block

Plan & Next Charge Summary

Current plan, next charge with an actual figure and date, payment method, and a prominent pending-cancellation state.

Billing & Usage151 lines1 dep
Open the full page for this block

Invoice History

Past invoices with tabular figures, worded statuses rather than bare coloured dots, and per-invoice download labels.

Billing & Usage174 lines1 dep
Open the full page for this block

Payment Methods On File

Cards on file with the two failures that cost the subscription: expiry called out before it fails, and the default stated on the row rather than implied by ordering.

Billing & Usage248 lines1 dep
Open the full page for this block

Subscription Cancel Flow

Self-serve cancellation with no dark patterns: the exact date access ends, what breaks, one honest alternative, and a button that works.

Billing & Usage273 lines1 dep
Open the full page for this block

Overage Notice with Projection

Past the included allowance, with the end-of-month projection and every number the invoice will use — because a surprise bill is a product defect.

Billing & Usage230 lines1 dep
Open the full page for this block

Invoice Detail

One invoice with the arithmetic shown: proration split into a credit and a charge with their own date ranges, tax as a line with its jurisdiction, and totals in a real tfoot.

Billing & Usage265 lines1 dep
Open the full page for this block

Credit Balance and Expiry

Consumable credits split into the buckets they actually live in, rendered in spend order, with a proportional warning that only appears when a meaningful balance is about to expire.

Billing & Usage222 lines1 dep