Plan & Next Charge Summary
Current plan, next charge with an actual figure and date, payment method, and a prominent pending-cancellation state.
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.
components/billing-seat-manager.tsxnpx hoverlab add billing-seat-manager
Or over MCP, from your editor's agent — no account needed.
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.
Start a page with this section — add more, order them, and leave with the page source.
£12.00 per seat per month · renews 14 September
9 of 12 seats are in use
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.
'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>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/billing-seat-manager.tsx and import it where you need the section:
import { BillingSeatManager } from '@/components/billing-seat-manager'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.
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.
| Prop | Type | Default |
|---|---|---|
purchasedSeats currently paid for. | number | 12 |
occupiedSeats with a person in them — the floor for any decrease. | number | 9 |
pricePerSeatPrice per seat per period, in the smallest currency unit. | number | 1200 |
currency | string | 'GBP' |
daysRemainingDays left in the current billing period, for the proration maths. | number | 18 |
periodDays | number | 30 |
renewsOn | string | '14 September' |
className | string | '' |
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.
<!--
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>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
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.
Want the whole screen instead of this one section? Open a page and copy it entire.
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 & UsageCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Current plan, next charge with an actual figure and date, payment method, and a prominent pending-cancellation state.
Past invoices with tabular figures, worded statuses rather than bare coloured dots, and per-invoice download labels.
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.
Self-serve cancellation with no dark patterns: the exact date access ends, what breaks, one honest alternative, and a button that works.
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.
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.
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.