Invoice List
Billing history rows with Paid / Due / Failed status, a Pay now that runs inline, and a PDF download per invoice.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/invoice-list.json'use client'
import { useState } from 'react'
import './invoice-list.css'
export interface Invoice { id: string; period: string; amount: number; status: 'paid' | 'due' | 'failed' }
export interface InvoiceListProps {
invoices: Invoice[]
onPay?: (id: string) => Promise<boolean>
onDownload?: (id: string) => void
fill?: 'dark' | 'light'
className?: string
}
const LABEL = { paid: 'Paid', due: 'Due', failed: 'Failed' }
/** Billing history with inline pay and download. */
export function InvoiceList({ invoices, onPay, onDownload, fill = 'dark', className }: InvoiceListProps) {
const [list, setList] = useState(invoices)
const [paying, setPaying] = useState<string | null>(null)
const [dl, setDl] = useState<string | null>(null)
const [msg, setMsg] = useState('')
const money = (c: number) => (c / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' })
const owed = list.filter((i) => i.status !== 'paid').reduce((a, i) => a + i.amount, 0)
const pay = async (inv: Invoice) => {
setPaying(inv.id)
const ok = onPay ? await onPay(inv.id) : await new Promise<boolean>((r) => setTimeout(() => r(true), 900))
setPaying(null)
setList((l) => l.map((i) => (i.id === inv.id ? { ...i, status: ok ? 'paid' : 'failed' } : i)))
setMsg(ok ? `${inv.period} paid` : `${inv.period} payment declined. Update your card.`)
}
return (
<div className={['sd-ivl', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-ivl__top"><b>Invoices</b><span aria-live="polite">{owed ? `${money(owed)} outstanding` : 'All paid'}</span></div>
{list.map((inv) => (
<div key={inv.id} className="sd-ivl__r">
<div><div className="sd-ivl__p">{inv.period}</div><div className="sd-ivl__a">{money(inv.amount)}</div></div>
<span key={inv.status} className="sd-ivl__st" data-s={inv.status}>{inv.status === 'paid' && <svg viewBox="0 0 12 12" aria-hidden><path d="m2.5 6.5 2.5 2.5 4.5-5.5" /></svg>}{LABEL[inv.status]}</span>
<div className="sd-ivl__acts">
{inv.status !== 'paid' && <button type="button" className="sd-ivl__pay" disabled={paying === inv.id} aria-label={`Pay ${inv.period}, ${money(inv.amount)}`} onClick={() => pay(inv)}>{paying === inv.id ? <span className="sd-ivl__spin" aria-hidden /> : inv.status === 'failed' ? 'Retry' : 'Pay now'}</button>}
<button type="button" className="sd-ivl__dl" data-busy={dl === inv.id || undefined} aria-label={`Download ${inv.period} invoice PDF`} onClick={() => { setDl(inv.id); onDownload?.(inv.id); setTimeout(() => setDl(null), 900) }}><svg viewBox="0 0 16 16" aria-hidden><path d="M8 2.5v8m-3.5-3.5L8 10.5 11.5 7M3 13.5h10" /></svg></button>
</div>
</div>
))}
<span className="sr" aria-live="polite">{msg}</span>
</div>
)
}.sd-ivl { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-ivl[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-ivl button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-ivl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-ivl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-ivl { width: min(100%, 340px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; font-variant-numeric: tabular-nums; }
.sd-ivl__top { display: flex; justify-content: space-between; align-items: baseline; padding: 14px 14px 10px; border-bottom: 1px solid var(--line); }
.sd-ivl__top b { font-size: 14px; }
.sd-ivl__top span { font-size: 12.5px; color: var(--mute); }
.sd-ivl__r { display: grid; grid-template-columns: 1fr auto auto; gap: 10px; align-items: center; padding: 11px 10px 11px 14px; border-bottom: 1px solid var(--line); }
.sd-ivl__r:last-child { border-bottom: 0; }
.sd-ivl__p { font-size: 13.5px; font-weight: 550; }
.sd-ivl__a { font-size: 12px; color: var(--mute); margin-top: 1px; }
.sd-ivl__st { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 99px; font-size: 11.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); animation: sd-ivl-in 260ms ease; }
.sd-ivl__st[data-s='failed'] { background: var(--strong); color: var(--on); box-shadow: none; }
.sd-ivl__st[data-s='due'] { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-ivl__st svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
@keyframes sd-ivl-in { from { transform: scale(0.85); opacity: 0; } }
.sd-ivl__acts { display: flex; gap: 4px; }
.sd-ivl__pay { height: 30px; padding: 0 10px; border-radius: 8px; background: var(--strong) !important; color: var(--on) !important; font-size: 12px; font-weight: 600; min-width: 64px; }
.sd-ivl__pay:disabled { opacity: 0.6; }
.sd-ivl__spin { display: inline-block; width: 12px; height: 12px; border-radius: 99px; border: 2px solid currentColor; border-right-color: transparent; animation: sd-ivl-rot 700ms linear infinite; vertical-align: -2px; }
@keyframes sd-ivl-rot { to { transform: rotate(360deg); } }
.sd-ivl__dl { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; color: var(--mute); }
.sd-ivl__dl:hover { color: var(--fg); background: var(--soft) !important; }
.sd-ivl__dl svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.sd-ivl__dl[data-busy] svg { animation: sd-ivl-drop 600ms ease infinite; }
@keyframes sd-ivl-drop { 50% { transform: translateY(2px); } }
@media (prefers-reduced-motion: reduce) { .sd-ivl *, .sd-ivl *::before, .sd-ivl *::after { animation: none !important; transition: none !important; } }Overview
Each row has the period, amount and a status label. Due and failed invoices get a Pay now button that shows a spinner, then flips the row to Paid with a tick.
Download shows a brief progress state. The header totals what is outstanding.
When to use
- Billing settings, customer portals.
When not to use
- Accounting exports: use a data table.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/invoice-list.json$
npx shadcn@latest add https://sundaeui.dev/r/invoice-list.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/invoice-list.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/invoice-list.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy invoice-list.tsx and invoice-list.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { InvoiceList } from '@/components/sundae/invoice-list'
<InvoiceList />Props
| Prop | Type | Default |
|---|---|---|
invoices*Invoices; amount in cents. | { id: string; period: string; amount: number; status: "paid" | "due" | "failed" }[] | — |
onPayPay an invoice. | (id: string) => Promise<boolean> | — |
onDownloadDownload handler. | (id: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Status is text, not only styling; actions are labelled with the invoice period.
- Payment results are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-ivl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Invoice List free?
- Yes. Invoice List is free for personal and commercial projects, with full source on this page.
- How do I install Invoice List?
- Run npx shadcn@latest add https://sundaeui.dev/r/invoice-list.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Invoice List support keyboard and screen readers?
- Status is text, not only styling; actions are labelled with the invoice period. Payment results are announced.
- Does Invoice List respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Usage Meters
Data · Understated- Pro
Price History
Data · Refined Commit Graph
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined