Tabular numerics

Money columns get font-variant-numeric: tabular-nums so digits align by column. Without it, proportional digits drift and the eye loses the row.

Without tabular-nums
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
With tabular-nums
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
css
/* Apply globally to money columns or wrap them with .tabular-nums */
.tabular-nums {
  font-variant-numeric: tabular-nums;
}
Semantic colors

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.

Up · positive

Revenue, balance growth, anything trending the right way. Same hex as --brand — single jade.

Down · negative

Spend variance over budget, declined transactions, alerts. Brand-constant across modes.

Flat · no change

No movement, draft state, archived data. Inherits --fg-muted.

Chart palette

Categorical series, drawn from the base palette. Maximum five — beyond that the eye stops separating them and the chart owes its reader a redesign.

01 Jade
02 Cobalt
03 Periwinkle
04 Ink
05 Graphite
What we don't ship
  • 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.

Number formatting

Intl.NumberFormat is the single source. Components never hand-roll thousands separators.

Full currency
$2,408,210
Tables, ledgers, exact-amount surfaces.
Compact currency
$2.4M
KPIs, dashboards, hero numbers.
Percent delta
+12.4%
Up signed with a plus, never bare.
Negative amount
−$240
Unicode minus U+2212, not the hyphen-minus.
Cardinal count
2,408,210 cards
Thousands grouped, units lowercase.
typescript
// 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"
typescript
// 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)
EnhancedTable for ledger data

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).

Preview
CardStatus
2026-05-09StripeEngineering · 4429-$2,890.00posted
2026-05-09AWSEngineering · 4429-$12,408.55posted
2026-05-08Meta AdsMarketing · 1180-$8,200.00posted
2026-05-08Google AdsMarketing · 1180-$6,044.12pending
2026-05-07VercelEngineering · 4429-$990.00posted
2026-05-07AnthropicEngineering · 4429-$2,000.00pending
2026-05-06DatadogEngineering · 4429-$1,480.00posted
2026-05-06NotionOperations · 7720-$240.00posted
svelte
<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}
/>
When to reach for it
  • 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.
When not to
  • Small static lists under ~20 rows — raw Table is lighter and reads cleaner.
  • Summary breakdowns where rows are aggregates, not records.
  • Header / overview rows inside a dashboard composition — use Card + KPIs.