Net Worth
Edit assets and debts line by line and watch net worth, the debt ratio and each line’s share update.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/net-worth.json'use client'
import { useState } from 'react'
import './net-worth.css'
interface Row { id: number; name: string; v: number }
let uid = 50
export interface NetWorthProps {
currency?: string
fill?: 'dark' | 'light'
className?: string
}
/** Editable assets and liabilities with net worth. */
export function NetWorth({ currency = 'USD', fill = 'dark', className }: NetWorthProps) {
const [tab, setTab] = useState<'a' | 'l'>('a')
const [rows, setRows] = useState<Record<'a' | 'l', Row[]>>({
a: [{ id: 1, name: 'Checking', v: 6200 }, { id: 2, name: 'Index funds', v: 48500 }, { id: 3, name: 'Retirement', v: 71000 }, { id: 4, name: 'Car', v: 14000 }],
l: [{ id: 5, name: 'Student loan', v: 23800 }, { id: 6, name: 'Car loan', v: 9100 }, { id: 7, name: 'Credit card', v: 1350 }],
})
const fmt = (n: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(n)
const sum = (k: 'a' | 'l') => rows[k].reduce((s, r) => s + r.v, 0)
const A = sum('a'), Lb = sum('l'), net = A - Lb
const ratio = A ? Math.round((Lb / A) * 100) : 0
const upd = (id: number, p: Partial<Row>) => setRows({ ...rows, [tab]: rows[tab].map((r) => (r.id === id ? { ...r, ...p } : r)) })
const side = sum(tab)
return (
<div className={['sd-nwt', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-nwt__top"><small>Net worth · debt is {ratio}% of assets</small><b aria-live="polite">{fmt(net)}</b></div>
<div className="sd-nwt__split" aria-hidden><i style={{ flexGrow: A || 1 }} /><i style={{ flexGrow: Lb }} /></div>
<div className="sd-nwt__leg"><span>Assets <b>{fmt(A)}</b></span><span>Liabilities <b>{fmt(Lb)}</b></span></div>
<div className="sd-nwt__seg" role="radiogroup" aria-label="Side">{([['a', `Assets · ${rows.a.length}`], ['l', `Liabilities · ${rows.l.length}`]] as const).map(([k, l]) => <button key={k} type="button" role="radio" aria-checked={tab === k} onClick={() => setTab(k)}>{l}</button>)}</div>
<ul aria-label={tab === 'a' ? 'Assets' : 'Liabilities'}>{rows[tab].map((r) => <li key={r.id} style={{ ['--w' as string]: `${side ? (r.v / side) * 100 : 0}%` }}>
<input className="sd-nwt__in" aria-label="Name" value={r.name} onChange={(e) => upd(r.id, { name: e.target.value })} />
<input className="sd-nwt__in" aria-label={`${r.name || 'Row'} amount`} inputMode="numeric" value={r.v ? r.v.toLocaleString('en-US') : ''} placeholder="0" onChange={(e) => upd(r.id, { v: Number(e.target.value.replace(/\D/g, '')) || 0 })} />
<button type="button" aria-label={`Remove ${r.name}`} onClick={() => setRows({ ...rows, [tab]: rows[tab].filter((x) => x.id !== r.id) })}>×</button>
</li>)}</ul>
<button type="button" className="sd-nwt__add" onClick={() => setRows({ ...rows, [tab]: [...rows[tab], { id: ++uid, name: tab === 'a' ? 'New asset' : 'New debt', v: 0 }] })}>+ Add {tab === 'a' ? 'asset' : 'liability'}</button>
</div>
)
}.sd-nwt { --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-nwt[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-nwt :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-nwt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-nwt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-nwt { width: min(100%, 340px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-nwt__seg { display: flex; padding: 2px; border-radius: 9px; background: var(--soft); } .sd-nwt__seg button { flex: 1; height: 30px; border-radius: 7px; font-size: 12px; color: var(--mute) !important; white-space: nowrap; padding: 0 4px; } .sd-nwt__seg button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1px var(--line); font-weight: 600; }
.sd-nwt__rg { display: grid; grid-template-columns: 76px minmax(0, 1fr) 56px; align-items: center; gap: 8px; font-size: 12px; color: var(--mute); } .sd-nwt__rg b { color: var(--fg); text-align: right; font-variant-numeric: tabular-nums; } .sd-nwt__rg input { width: 100%; margin: 0; accent-color: var(--fg); }
.sd-nwt__in { width: 100%; min-width: 0; height: 36px; padding: 0 10px; border-radius: 10px; border: 0; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; outline: 0; box-sizing: border-box; } .sd-nwt__in:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-nwt__top small { font-size: 11px; color: var(--mute); }
.sd-nwt__top b { display: block; font-size: 28px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.sd-nwt__split { display: flex; height: 8px; border-radius: 4px; overflow: hidden; gap: 2px; }
.sd-nwt__split i { background: var(--fg); transition: flex-grow 250ms; }
.sd-nwt__split i + i { background: var(--mute); opacity: 0.5; }
.sd-nwt__leg { display: flex; justify-content: space-between; font-size: 11px; color: var(--mute); margin-top: -4px; }
.sd-nwt__leg b { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-nwt ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; max-height: 200px; overflow-y: auto; }
.sd-nwt li { display: grid; grid-template-columns: minmax(0, 1fr) 96px 22px; gap: 5px; align-items: center; position: relative; padding-bottom: 5px; }
.sd-nwt li::after { content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: var(--w); border-radius: 1px; background: var(--fg); opacity: 0.5; transition: width 250ms; }
.sd-nwt li input { height: 32px; font-size: 12.5px; }
.sd-nwt li input[inputmode] { text-align: right; font-variant-numeric: tabular-nums; }
.sd-nwt li button { width: 22px; height: 22px; border-radius: 6px; color: var(--mute) !important; font-size: 15px; }
.sd-nwt__add { height: 32px; border-radius: 9px; font-size: 12px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
@media (prefers-reduced-motion: reduce) { .sd-nwt *, .sd-nwt *::before, .sd-nwt *::after { animation: none !important; transition: none !important; } }Overview
Assets and liabilities are tabs of editable rows with amounts. Each row shows its share of its side as a thin bar.
The headline shows net worth with the debt-to-asset ratio, and a split bar compares the two sides at a glance.
When to use
- Personal finance apps, advisor dashboards, year-end reviews.
When not to use
- Live account balances: connect to an aggregator.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/net-worth.json$
npx shadcn@latest add https://sundaeui.dev/r/net-worth.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/net-worth.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/net-worth.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 net-worth.tsx and net-worth.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 { NetWorth } from '@/components/sundae/net-worth'
<NetWorth />Props
| Prop | Type | Default |
|---|---|---|
currencyISO currency code. | string | 'USD' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Rows have labelled name and amount inputs.
- Net worth is a live region.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-nwt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Net Worth free?
- Yes. Net Worth is free for personal and commercial projects, with full source on this page.
- How do I install Net Worth?
- Run npx shadcn@latest add https://sundaeui.dev/r/net-worth.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Net Worth support keyboard and screen readers?
- Rows have labelled name and amount inputs. Net worth is a live region.
- Does Net Worth respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Savings Goal
Data · RefinedSubscription Tracker
Data · Refined- Pro
Treemap
Data · Refined Stacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined