Data viz.
Numbers above all. Tables, charts, and KPIs share one palette and one number-formatting opinion.
Money columns get font-variant-numeric: tabular-nums so digits align by column. Without it, proportional digits drift and the eye loses the row.
| Operations | $2,408,210.55 | $182,003.10 | $24.95 |
| Vendors | $986,540.00 | $1,400,082.22 | $1,002.00 |
| Travel | $47,200.40 | $8,710.00 | $220.50 |
| Operations | $2,408,210.55 | $182,003.10 | $24.95 |
| Vendors | $986,540.00 | $1,400,082.22 | $1,002.00 |
| Travel | $47,200.40 | $8,710.00 | $220.50 |
/* Apply globally to money columns or wrap them with .tabular-nums */
.tabular-nums {
font-variant-numeric: tabular-nums;
}Three colours, three meanings. Up is jade, down is orange, flat is muted. No green vs. red — that swap is the one fintech tell we don't repeat.
Categorical series, drawn from the base palette. Maximum five — beyond that the eye stops separating them and the chart owes its reader a redesign.
- Pie. Slice angles read worse than bar heights for every comparison.
- Donut. Pie with worse data-ink ratio.
- Radar. Polygons hide direction and magnitude both.
Bar, line, and area cover ~95% of fintech needs and are honest about magnitude. When you need a part-to-whole view, use a stacked bar.
Intl.NumberFormat is the single source. Components never hand-roll thousands separators.
// Full currency for tables and ledgers
const fmtMoney = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
fmtMoney.format(2408210.55); // "$2,408,210.55"
// Compact currency for KPIs and dashboards
const fmtCompact = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1
});
fmtCompact.format(2408210); // "$2.4M"// Signed percentage delta with locale-aware minus glyph
const fmtDelta = new Intl.NumberFormat('en-US', {
style: 'percent',
signDisplay: 'exceptZero',
minimumFractionDigits: 1,
maximumFractionDigits: 1
});
fmtDelta.format(0.124); // "+12.4%"
fmtDelta.format(-0.018); // "−1.8%" (uses U+2212)The workhorse for any ledger, transaction, or activity view in Dash.fi. Sortable columns, integrated pagination, filter toolbar, column visibility controls, row selection. Built on @tanstack/table-core — column defs are TanStack defs extended with a few Dash conveniences (align, sortable, cellClassName, sticky).
| Card | Status | |||
|---|---|---|---|---|
| 2026-05-09 | Stripe | Engineering · 4429 | -$2,890.00 | posted |
| 2026-05-09 | AWS | Engineering · 4429 | -$12,408.55 | posted |
| 2026-05-08 | Meta Ads | Marketing · 1180 | -$8,200.00 | posted |
| 2026-05-08 | Google Ads | Marketing · 1180 | -$6,044.12 | pending |
| 2026-05-07 | Vercel | Engineering · 4429 | -$990.00 | posted |
| 2026-05-07 | Anthropic | Engineering · 4429 | -$2,000.00 | pending |
| 2026-05-06 | Datadog | Engineering · 4429 | -$1,480.00 | posted |
| 2026-05-06 | Notion | Operations · 7720 | -$240.00 | posted |
<script lang="ts">
import { EnhancedTable, type EnhancedTableColumnDef } from '@dashfi/svelte/ui/enhanced-table';
import { renderSnippet } from '@dashfi/svelte/ui/data-table';
import { Pill } from '@dashfi/svelte/ui/pill';
type LedgerStatus = 'posted' | 'pending';
type LedgerRow = {
id: string;
date: string;
merchant: string;
card: string;
amount: number;
status: LedgerStatus;
};
const rows: LedgerRow[] = [/* ... */];
// signDisplay: 'exceptZero' emits the U+2212 minus glyph automatically.
const fmtAmount = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
signDisplay: 'exceptZero',
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
const columns: EnhancedTableColumnDef<LedgerRow>[] = [
{ accessorKey: 'date', header: 'Date', sortable: true, cellClassName: 'font-mono tabular-nums' },
{ accessorKey: 'merchant', header: 'Merchant', sortable: true },
{ accessorKey: 'card', header: 'Card', cellClassName: 'text-muted-foreground' },
{
accessorKey: 'amount',
header: 'Amount',
sortable: true,
align: 'right',
cellClassName: 'font-mono tabular-nums',
cell: ({ row }) => fmtAmount.format(row.getValue<number>('amount'))
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => renderSnippet(statusPill, row.getValue<LedgerStatus>('status'))
}
];
</script>
{#snippet statusPill(status: LedgerStatus)}
<Pill type={status === 'pending' ? 'warning' : 'base'}>{status}</Pill>
{/snippet}
<EnhancedTable
{columns}
bind:data={rows}
searchable
searchPlaceholder="Search merchants or cards"
pageSize={10}
/>- Datasets larger than ~50 rows — pagination becomes load-bearing, not optional.
- Users need sorting, search, or pagination without building a custom toolbar.
- Column visibility should persist per user (customizable activity views).
- Row-level selection drives a bulk action (export, categorize, reconcile).
- Server-side fetching with
totalItems+onPaginationChange.
- Small static lists under ~20 rows — raw
Tableis lighter and reads cleaner. - Summary breakdowns where rows are aggregates, not records.
- Header / overview rows inside a dashboard composition — use
Card+ KPIs.