Invoice 1: invoice you can pay
An invoice you can read, print and pay: parties, dates and line items, totals worked out from the lines, a status from the dates (paid, due in N days, overdue by N days) and a Pay now that handles waiting and failure.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Invoice 1. https://ui.ballmac.com/blocks/invoice-1
"use client"
import * as React from "react"
import { CircleAlert, CircleCheck, Clock, CreditCard, Download, Loader2, Printer } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
type Invoice1Line = { id: string; description: string; detail?: string; quantity: number; unitPrice: number }
type Invoice1Party = { name: string; lines: string[] }
type Invoice1Data = {
number: string
/** ISO dates. */
issued: string
due: string
/** Set when it has been paid. */
paidOn?: string
from: Invoice1Party
to: Invoice1Party
lines: Invoice1Line[]
/** Percent off the subtotal, 0 to 100. */
discountPercent?: number
/** Tax rate in percent. */
taxPercent?: number
note?: string
/** How to pay by transfer, shown when unpaid. */
paymentDetails?: { label: string; value: string }[]
}
type Invoice1Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
invoice?: Invoice1Data
/** An ISO date for "today", used to say whether it is overdue. The block never reads the clock. */
today?: string
currency?: string
locale?: string
/** Called by Pay now. Return a promise; reject to show its message. Leave out to hide the button. */
onPay?: () => void | Promise<void>
/** Called by Download. Leave out to hide the button. */
onDownload?: () => void
}
const sample: Invoice1Data = {
number: "INV-2041",
issued: "2026-10-01",
due: "2026-10-15",
from: { name: "Acme Studio Ltd", lines: ["Rua da Prata 120", "1100-420 Lisboa, Portugal", "VAT PT 509 123 456"] },
to: { name: "Northwind Logistics", lines: ["Attn: Elena Marsh", "88 Orchard St", "New York, NY 10002"] },
lines: [
{ id: "1", description: "Product design sprint", detail: "Two weeks, three designers", quantity: 1, unitPrice: 9600 },
{ id: "2", description: "Design system audit", detail: "Components, tokens and accessibility", quantity: 1, unitPrice: 3200 },
{ id: "3", description: "Workshop days", detail: "On site, including travel", quantity: 2, unitPrice: 1450 },
{ id: "4", description: "Prototype hosting", detail: "Per month", quantity: 3, unitPrice: 40 },
],
discountPercent: 5,
taxPercent: 0,
note: "Thank you for working with us. Payment is due within 14 days. A late fee of 1.5% a month applies after that.",
paymentDetails: [
{ label: "Bank", value: "Banco Atlântico" },
{ label: "IBAN", value: "PT50 0002 0123 1234 5678 9015 4" },
{ label: "Reference", value: "INV-2041" },
],
}
const DAY = 86_400_000
const toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`)
function Skel() {
return (
<div aria-hidden="true" className="bg-card rounded-3xl border p-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-8 h-24" />
<Skeleton className="mt-8 h-56" />
</div>
)
}
/**
* An invoice you can read, print and pay: who it is from and to, the dates, each line with its quantity and amount, then the subtotal,
* discount, tax and total, all worked out from the lines. Its status (paid, due in N days, overdue by N days) comes from the dates and the day
* you pass, never the clock. Pay now handles the wait and a failure, and the print view hides the buttons.
*/
function Invoice1({ invoice = sample, today = "2026-10-18", currency = "USD", locale = "en", onPay, onDownload, state = "ready", onRetry, errorState, className, ...props }: Invoice1Props) {
const id = React.useId()
const [paidNow, setPaidNow] = React.useState(false)
const [paying, setPaying] = React.useState(false)
const [payError, setPayError] = React.useState<string>()
const money = (n: number) => new Intl.NumberFormat(locale, { style: "currency", currency }).format(n)
const date = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }), [locale])
const subtotal = invoice.lines.reduce((n, l) => n + l.quantity * l.unitPrice, 0)
const discount = (subtotal * (invoice.discountPercent ?? 0)) / 100
const taxable = subtotal - discount
const tax = (taxable * (invoice.taxPercent ?? 0)) / 100
const total = taxable + tax
const paid = !!invoice.paidOn || paidNow
const daysLate = Math.floor((toT(today) - toT(invoice.due)) / DAY)
const status = paid
? { tone: "paid" as const, label: "Paid", note: invoice.paidOn ? `on ${date.format(toT(invoice.paidOn))}` : "just now", icon: CircleCheck }
: daysLate > 0
? { tone: "late" as const, label: "Overdue", note: `by ${daysLate} ${daysLate === 1 ? "day" : "days"}`, icon: CircleAlert }
: { tone: "due" as const, label: "Due", note: daysLate === 0 ? "today" : `in ${-daysLate} ${-daysLate === 1 ? "day" : "days"}`, icon: Clock }
const tone = { paid: "bg-chart-2/10 border-chart-2/30", late: "bg-destructive/10 border-destructive/30", due: "bg-amber-500/10 border-amber-500/40" }[status.tone]
async function pay() {
setPayError(undefined)
setPaying(true)
try {
await onPay?.()
setPaidNow(true)
} catch (e) {
setPayError(e instanceof Error ? e.message : "The payment did not go through. You have not been charged.")
} finally {
setPaying(false)
}
}
const content = (
<article aria-labelledby={`${id}-title`} className="bg-card overflow-hidden rounded-3xl border shadow-sm print:border-0 print:shadow-none">
<div className={cn("flex flex-wrap items-center justify-between gap-4 border-b px-6 py-4 sm:px-10", tone)}>
<p role="status" className="flex items-center gap-2.5 text-sm font-semibold">
<status.icon aria-hidden="true" className="size-5" />
{status.label} <span className="font-normal">{status.note}</span>
</p>
<div className="flex flex-wrap gap-2 print:hidden">
{onDownload ? (
<Button variant="outline" size="sm" onClick={onDownload}>
<Download aria-hidden="true" />
Download
</Button>
) : null}
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Printer aria-hidden="true" />
Print
</Button>
</div>
</div>
<div className="p-6 sm:p-10">
<div className="flex flex-wrap items-start justify-between gap-6">
<div>
<span aria-hidden="true" className="bg-foreground text-background flex size-11 items-center justify-center rounded-xl text-lg font-semibold">
{invoice.from.name[0]}
</span>
<h2 id={`${id}-title`} className="mt-5 text-3xl font-semibold tracking-[-0.04em]">
Invoice <span className="tabular-nums">{invoice.number}</span>
</h2>
</div>
<div className="text-end">
<p className="text-muted-foreground text-sm">Amount {paid ? "paid" : "due"}</p>
<p className="text-4xl font-semibold tracking-[-0.045em] tabular-nums sm:text-5xl">{money(total)}</p>
</div>
</div>
<div className="mt-10 grid gap-8 sm:grid-cols-3">
<dl>
<div>
<dt className="text-muted-foreground text-xs font-medium tracking-wide uppercase">From</dt>
<dd className="mt-2 text-sm leading-relaxed">
<span className="block font-semibold">{invoice.from.name}</span>
{invoice.from.lines.map((l) => (
<span key={l} className="text-muted-foreground block">
{l}
</span>
))}
</dd>
</div>
</dl>
<dl>
<div>
<dt className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Billed to</dt>
<dd className="mt-2 text-sm leading-relaxed">
<span className="block font-semibold">{invoice.to.name}</span>
{invoice.to.lines.map((l) => (
<span key={l} className="text-muted-foreground block">
{l}
</span>
))}
</dd>
</div>
</dl>
<dl className="grid content-start gap-3 text-sm">
<div>
<dt className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Issued</dt>
<dd className="mt-1 font-medium">{date.format(toT(invoice.issued))}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs font-medium tracking-wide uppercase">{paid ? "Was due" : "Due"}</dt>
<dd className="mt-1 font-medium">{date.format(toT(invoice.due))}</dd>
</div>
</dl>
</div>
<div role="region" tabIndex={0} aria-label="Invoice items" className="focus-visible:ring-ring/50 mt-10 overflow-x-auto outline-none focus-visible:ring-[3px]">
<table className="w-full min-w-[34rem] text-sm">
<caption className="sr-only">Items on invoice {invoice.number}</caption>
<thead>
<tr className="text-muted-foreground border-b text-xs font-medium tracking-wide uppercase">
<th scope="col" className="py-3 pe-4 text-start font-medium">Description</th>
<th scope="col" className="px-4 py-3 text-end font-medium">Qty</th>
<th scope="col" className="px-4 py-3 text-end font-medium">Unit price</th>
<th scope="col" className="py-3 ps-4 text-end font-medium">Amount</th>
</tr>
</thead>
<tbody className="divide-y">
{invoice.lines.map((l) => (
<tr key={l.id}>
<td className="py-4 pe-4">
<p className="font-medium">{l.description}</p>
{l.detail ? <p className="text-muted-foreground mt-0.5 text-xs">{l.detail}</p> : null}
</td>
<td className="px-4 py-4 text-end tabular-nums">{l.quantity}</td>
<td className="px-4 py-4 text-end tabular-nums">{money(l.unitPrice)}</td>
<td className="py-4 ps-4 text-end font-medium tabular-nums">{money(l.quantity * l.unitPrice)}</td>
</tr>
))}
</tbody>
</table>
</div>
<dl className="mt-6 ms-auto grid max-w-xs gap-2.5 text-sm">
<div className="flex justify-between gap-6">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{money(subtotal)}</dd>
</div>
{discount ? (
<div className="flex justify-between gap-6">
<dt className="text-muted-foreground">Discount ({invoice.discountPercent}%)</dt>
<dd className="tabular-nums">−{money(discount)}</dd>
</div>
) : null}
<div className="flex justify-between gap-6">
<dt className="text-muted-foreground">Tax{invoice.taxPercent ? ` (${invoice.taxPercent}%)` : ""}</dt>
<dd className="tabular-nums">{money(tax)}</dd>
</div>
<div className="flex justify-between gap-6 border-t pt-3 text-base font-semibold">
<dt>Total</dt>
<dd className="tabular-nums">{money(total)}</dd>
</div>
</dl>
{!paid && invoice.paymentDetails?.length ? (
<div className="bg-muted/50 mt-10 rounded-2xl border p-5">
<h3 className="text-sm font-semibold">Pay by bank transfer</h3>
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-[6rem_minmax(0,1fr)]">
{invoice.paymentDetails.map((d) => (
<React.Fragment key={d.label}>
<dt className="text-muted-foreground">{d.label}</dt>
<dd className="font-medium tabular-nums" dir="ltr">
{d.value}
</dd>
</React.Fragment>
))}
</dl>
</div>
) : null}
{invoice.note ? <p className="text-muted-foreground mt-8 max-w-xl text-sm text-pretty">{invoice.note}</p> : null}
{!paid && onPay ? (
<div className="mt-8 border-t pt-6 print:hidden">
<Button size="lg" onClick={pay} disabled={paying} aria-busy={paying}>
{paying ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <CreditCard aria-hidden="true" />}
{paying ? "Paying" : `Pay ${money(total)} now`}
</Button>
{payError ? (
<p role="alert" className="text-destructive mt-3 flex items-start gap-2 text-sm">
<CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
{payError}
</p>
) : null}
</div>
) : null}
</div>
</article>
)
return (
<section data-slot="invoice-1" data-state={state} aria-label={`Invoice ${invoice.number}`} className={cn("mx-auto w-full max-w-3xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
<StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the invoice" loading={<Skel />} error={{ title: errorState?.title ?? "The invoice could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}>
{content}
</StateView>
</section>
)
}
export { Invoice1, type Invoice1Props, type Invoice1Data, type Invoice1Line }Installation
$ pnpm dlx shadcn@latest add @ballmac/invoice-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/invoice-1/invoice-1.tsx// Ballmac UI: Invoice 1. https://ui.ballmac.com/blocks/invoice-1 "use client" import * as React from "react" import { CircleAlert, CircleCheck, Clock, CreditCard, Download, Loader2, Printer } from "lucide-react" import { Button } from "@/components/ballmac/button" import { Skeleton } from "@/components/ballmac/skeleton" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { cn } from "@/lib/utils" type Invoice1Line = { id: string; description: string; detail?: string; quantity: number; unitPrice: number } type Invoice1Party = { name: string; lines: string[] } type Invoice1Data = { number: string /** ISO dates. */ issued: string due: string /** Set when it has been paid. */ paidOn?: string from: Invoice1Party to: Invoice1Party lines: Invoice1Line[] /** Percent off the subtotal, 0 to 100. */ discountPercent?: number /** Tax rate in percent. */ taxPercent?: number note?: string /** How to pay by transfer, shown when unpaid. */ paymentDetails?: { label: string; value: string }[] } type Invoice1Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { invoice?: Invoice1Data /** An ISO date for "today", used to say whether it is overdue. The block never reads the clock. */ today?: string currency?: string locale?: string /** Called by Pay now. Return a promise; reject to show its message. Leave out to hide the button. */ onPay?: () => void | Promise<void> /** Called by Download. Leave out to hide the button. */ onDownload?: () => void } const sample: Invoice1Data = { number: "INV-2041", issued: "2026-10-01", due: "2026-10-15", from: { name: "Acme Studio Ltd", lines: ["Rua da Prata 120", "1100-420 Lisboa, Portugal", "VAT PT 509 123 456"] }, to: { name: "Northwind Logistics", lines: ["Attn: Elena Marsh", "88 Orchard St", "New York, NY 10002"] }, lines: [ { id: "1", description: "Product design sprint", detail: "Two weeks, three designers", quantity: 1, unitPrice: 9600 }, { id: "2", description: "Design system audit", detail: "Components, tokens and accessibility", quantity: 1, unitPrice: 3200 }, { id: "3", description: "Workshop days", detail: "On site, including travel", quantity: 2, unitPrice: 1450 }, { id: "4", description: "Prototype hosting", detail: "Per month", quantity: 3, unitPrice: 40 }, ], discountPercent: 5, taxPercent: 0, note: "Thank you for working with us. Payment is due within 14 days. A late fee of 1.5% a month applies after that.", paymentDetails: [ { label: "Bank", value: "Banco Atlântico" }, { label: "IBAN", value: "PT50 0002 0123 1234 5678 9015 4" }, { label: "Reference", value: "INV-2041" }, ], } const DAY = 86_400_000 const toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`) function Skel() { return ( <div aria-hidden="true" className="bg-card rounded-3xl border p-8"> <Skeleton className="h-8 w-48" /> <Skeleton className="mt-8 h-24" /> <Skeleton className="mt-8 h-56" /> </div> ) } /** * An invoice you can read, print and pay: who it is from and to, the dates, each line with its quantity and amount, then the subtotal, * discount, tax and total, all worked out from the lines. Its status (paid, due in N days, overdue by N days) comes from the dates and the day * you pass, never the clock. Pay now handles the wait and a failure, and the print view hides the buttons. */ function Invoice1({ invoice = sample, today = "2026-10-18", currency = "USD", locale = "en", onPay, onDownload, state = "ready", onRetry, errorState, className, ...props }: Invoice1Props) { const id = React.useId() const [paidNow, setPaidNow] = React.useState(false) const [paying, setPaying] = React.useState(false) const [payError, setPayError] = React.useState<string>() const money = (n: number) => new Intl.NumberFormat(locale, { style: "currency", currency }).format(n) const date = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }), [locale]) const subtotal = invoice.lines.reduce((n, l) => n + l.quantity * l.unitPrice, 0) const discount = (subtotal * (invoice.discountPercent ?? 0)) / 100 const taxable = subtotal - discount const tax = (taxable * (invoice.taxPercent ?? 0)) / 100 const total = taxable + tax const paid = !!invoice.paidOn || paidNow const daysLate = Math.floor((toT(today) - toT(invoice.due)) / DAY) const status = paid ? { tone: "paid" as const, label: "Paid", note: invoice.paidOn ? `on ${date.format(toT(invoice.paidOn))}` : "just now", icon: CircleCheck } : daysLate > 0 ? { tone: "late" as const, label: "Overdue", note: `by ${daysLate} ${daysLate === 1 ? "day" : "days"}`, icon: CircleAlert } : { tone: "due" as const, label: "Due", note: daysLate === 0 ? "today" : `in ${-daysLate} ${-daysLate === 1 ? "day" : "days"}`, icon: Clock } const tone = { paid: "bg-chart-2/10 border-chart-2/30", late: "bg-destructive/10 border-destructive/30", due: "bg-amber-500/10 border-amber-500/40" }[status.tone] async function pay() { setPayError(undefined) setPaying(true) try { await onPay?.() setPaidNow(true) } catch (e) { setPayError(e instanceof Error ? e.message : "The payment did not go through. You have not been charged.") } finally { setPaying(false) } } const content = ( <article aria-labelledby={`${id}-title`} className="bg-card overflow-hidden rounded-3xl border shadow-sm print:border-0 print:shadow-none"> <div className={cn("flex flex-wrap items-center justify-between gap-4 border-b px-6 py-4 sm:px-10", tone)}> <p role="status" className="flex items-center gap-2.5 text-sm font-semibold"> <status.icon aria-hidden="true" className="size-5" /> {status.label} <span className="font-normal">{status.note}</span> </p> <div className="flex flex-wrap gap-2 print:hidden"> {onDownload ? ( <Button variant="outline" size="sm" onClick={onDownload}> <Download aria-hidden="true" /> Download </Button> ) : null} <Button variant="outline" size="sm" onClick={() => window.print()}> <Printer aria-hidden="true" /> Print </Button> </div> </div> <div className="p-6 sm:p-10"> <div className="flex flex-wrap items-start justify-between gap-6"> <div> <span aria-hidden="true" className="bg-foreground text-background flex size-11 items-center justify-center rounded-xl text-lg font-semibold"> {invoice.from.name[0]} </span> <h2 id={`${id}-title`} className="mt-5 text-3xl font-semibold tracking-[-0.04em]"> Invoice <span className="tabular-nums">{invoice.number}</span> </h2> </div> <div className="text-end"> <p className="text-muted-foreground text-sm">Amount {paid ? "paid" : "due"}</p> <p className="text-4xl font-semibold tracking-[-0.045em] tabular-nums sm:text-5xl">{money(total)}</p> </div> </div> <div className="mt-10 grid gap-8 sm:grid-cols-3"> <dl> <div> <dt className="text-muted-foreground text-xs font-medium tracking-wide uppercase">From</dt> <dd className="mt-2 text-sm leading-relaxed"> <span className="block font-semibold">{invoice.from.name}</span> {invoice.from.lines.map((l) => ( <span key={l} className="text-muted-foreground block"> {l} </span> ))} </dd> </div> </dl> <dl> <div> <dt className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Billed to</dt> <dd className="mt-2 text-sm leading-relaxed"> <span className="block font-semibold">{invoice.to.name}</span> {invoice.to.lines.map((l) => ( <span key={l} className="text-muted-foreground block"> {l} </span> ))} </dd> </div> </dl> <dl className="grid content-start gap-3 text-sm"> <div> <dt className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Issued</dt> <dd className="mt-1 font-medium">{date.format(toT(invoice.issued))}</dd> </div> <div> <dt className="text-muted-foreground text-xs font-medium tracking-wide uppercase">{paid ? "Was due" : "Due"}</dt> <dd className="mt-1 font-medium">{date.format(toT(invoice.due))}</dd> </div> </dl> </div> <div role="region" tabIndex={0} aria-label="Invoice items" className="focus-visible:ring-ring/50 mt-10 overflow-x-auto outline-none focus-visible:ring-[3px]"> <table className="w-full min-w-[34rem] text-sm"> <caption className="sr-only">Items on invoice {invoice.number}</caption> <thead> <tr className="text-muted-foreground border-b text-xs font-medium tracking-wide uppercase"> <th scope="col" className="py-3 pe-4 text-start font-medium">Description</th> <th scope="col" className="px-4 py-3 text-end font-medium">Qty</th> <th scope="col" className="px-4 py-3 text-end font-medium">Unit price</th> <th scope="col" className="py-3 ps-4 text-end font-medium">Amount</th> </tr> </thead> <tbody className="divide-y"> {invoice.lines.map((l) => ( <tr key={l.id}> <td className="py-4 pe-4"> <p className="font-medium">{l.description}</p> {l.detail ? <p className="text-muted-foreground mt-0.5 text-xs">{l.detail}</p> : null} </td> <td className="px-4 py-4 text-end tabular-nums">{l.quantity}</td> <td className="px-4 py-4 text-end tabular-nums">{money(l.unitPrice)}</td> <td className="py-4 ps-4 text-end font-medium tabular-nums">{money(l.quantity * l.unitPrice)}</td> </tr> ))} </tbody> </table> </div> <dl className="mt-6 ms-auto grid max-w-xs gap-2.5 text-sm"> <div className="flex justify-between gap-6"> <dt className="text-muted-foreground">Subtotal</dt> <dd className="tabular-nums">{money(subtotal)}</dd> </div> {discount ? ( <div className="flex justify-between gap-6"> <dt className="text-muted-foreground">Discount ({invoice.discountPercent}%)</dt> <dd className="tabular-nums">−{money(discount)}</dd> </div> ) : null} <div className="flex justify-between gap-6"> <dt className="text-muted-foreground">Tax{invoice.taxPercent ? ` (${invoice.taxPercent}%)` : ""}</dt> <dd className="tabular-nums">{money(tax)}</dd> </div> <div className="flex justify-between gap-6 border-t pt-3 text-base font-semibold"> <dt>Total</dt> <dd className="tabular-nums">{money(total)}</dd> </div> </dl> {!paid && invoice.paymentDetails?.length ? ( <div className="bg-muted/50 mt-10 rounded-2xl border p-5"> <h3 className="text-sm font-semibold">Pay by bank transfer</h3> <dl className="mt-3 grid gap-2 text-sm sm:grid-cols-[6rem_minmax(0,1fr)]"> {invoice.paymentDetails.map((d) => ( <React.Fragment key={d.label}> <dt className="text-muted-foreground">{d.label}</dt> <dd className="font-medium tabular-nums" dir="ltr"> {d.value} </dd> </React.Fragment> ))} </dl> </div> ) : null} {invoice.note ? <p className="text-muted-foreground mt-8 max-w-xl text-sm text-pretty">{invoice.note}</p> : null} {!paid && onPay ? ( <div className="mt-8 border-t pt-6 print:hidden"> <Button size="lg" onClick={pay} disabled={paying} aria-busy={paying}> {paying ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <CreditCard aria-hidden="true" />} {paying ? "Paying" : `Pay ${money(total)} now`} </Button> {payError ? ( <p role="alert" className="text-destructive mt-3 flex items-start gap-2 text-sm"> <CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> {payError} </p> ) : null} </div> ) : null} </div> </article> ) return ( <section data-slot="invoice-1" data-state={state} aria-label={`Invoice ${invoice.number}`} className={cn("mx-auto w-full max-w-3xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}> <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the invoice" loading={<Skel />} error={{ title: errorState?.title ?? "The invoice could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}> {content} </StateView> </section> ) } export { Invoice1, type Invoice1Props, type Invoice1Data, type Invoice1Line }Update the import paths to match your project setup.
Usage
"use client"
import { Invoice1 } from "@/components/ballmac/blocks/invoice-1/invoice-1"
export default function Invoice1Demo() {
return <Invoice1 onPay={() => new Promise((r) => setTimeout(r, 900))} onDownload={() => {}} />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
A skeleton sheet announced as loading.
state="loading"Could not load
An alert with Try again (onRetry). A failed payment says you were not charged, in place beside Pay now.
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 5 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (1% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
11 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, error states, each with an example.
Passed: Automated tests
Covered by invoice-1.test.tsx.
For information: Footprint
4.2 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
An invoice. <Invoice1 invoice={{ number, issued, due, paidOn, from: { name, lines }, to: { name, lines }, lines: [{ id, description, detail, quantity, unitPrice }], discountPercent, taxPercent, note, paymentDetails: [{ label, value }] }} today='2026-10-18' currency='USD' locale onPay={() => Promise} onDownload />. Subtotal, discount, tax and total are worked out from the lines; status is paid (paidOn set or payment succeeded), overdue by N days, or due in N days, from due and today. Pass today yourself; the block never reads the clock. Reject onPay to show its message. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Invoice 1: invoice you can pay (@ballmac/invoice-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A customer-facing invoice page or a payment link
- An invoice detail in a billing area
Not for
- A list of invoices (use a data table)
- Changing plans with proration (use billing-2)
Registry JSON: https://ui.ballmac.com/r/invoice-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.