Template · React · HTML
Currency wallet
A multi-currency wallet in Tiles: a currency switch, a rewards panel that names the next tier, a spending donut and allocation bars.
Preview
React
Install the template with shadcn. It lands in your project as ordinary components that you edit yourself.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/currency-wallet.jsonnpx shadcn@latest add @manniche/currency-walletcurrency-wallet.tsx
// A multi-currency wallet in Tiles: a currency switch, a rewards panel, a spending donut and allocation bars.
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { BigNumber, DeltaPill, Pills } from '@/registry/manniche/chart-kit/chart-kit'
import { DataTile, TileFact } from '@/registry/manniche/data-tile/data-tile'
import { Donut, type DonutDatum } from '@/registry/manniche/donut/donut'
export type WalletCurrency = {
/** ISO code, also the switch label: "EUR". */
code: string
balance: number
change: number
spending: DonutDatum[]
/** Share of the balance per holding, 0 to 1. */
allocation: { id: string; label: string; share: number }[]
/** `tier` is the current tier, `nextTier` the one `next` more points reach. */
rewards: { points: number; next: number; tier: string; nextTier: string; perks: string[] }
}
export type CurrencyWalletProps = {
data: WalletCurrency[]
defaultCurrency?: string
title?: string
note?: string
now?: Date
labels?: { currency?: string; balance?: string; rewards?: string; spending?: string; allocation?: string; points?: string; toNext?: (n: number, tier: string) => string }
className?: string
}
/** Currency wallet: switch the currency and every tile follows. */
export function CurrencyWallet({ data, defaultCurrency, title = 'Currency wallet', note = 'Example data.', labels, className }: CurrencyWalletProps) {
const l = {
currency: 'Currency',
balance: 'Balance',
rewards: 'Rewards',
spending: 'Spending',
allocation: 'Allocation',
points: 'points',
toNext: (n: number, tier: string) => `${n.toLocaleString('en-GB')} points to ${tier}`,
...labels,
}
const [code, setCode] = useState(defaultCurrency ?? data[0]?.code ?? '')
const cur = data.find((c) => c.code === code) ?? data[0]
if (!cur) return null
const fmt = { currency: cur.code, decimals: 2 }
const toGo = cur.rewards.points + cur.rewards.next
const progress = toGo > 0 ? Math.min(1, Math.max(0, cur.rewards.points / toGo)) : 0
return (
<div className={cn('@container w-full font-sans text-foreground', className)}>
<div className="grid gap-4 @3xl:grid-cols-6 @6xl:grid-cols-12">
<div className="flex flex-wrap items-center justify-between gap-3 @3xl:col-span-6 @6xl:col-span-12">
<h1 className="text-[22px] leading-tight font-semibold tracking-tight">{title}</h1>
<Pills label={l.currency} options={data.map((c) => ({ id: c.code, label: c.code }))} value={cur.code} onChange={setCode} />
</div>
<DataTile title={l.balance} className="@3xl:col-span-3 @6xl:col-span-4" footer={<span>{note}</span>}>
<BigNumber value={cur.balance} format={fmt} size="lg" />
<div className="mt-3">
<DeltaPill value={cur.change} format={{ decimals: 1, suffix: '%', sign: true }} />
</div>
</DataTile>
<DataTile title={l.rewards} inverted className="@3xl:col-span-3 @6xl:col-span-4">
<BigNumber value={cur.rewards.points} size="lg" />
<p className="mt-1 text-sm opacity-80">{l.points} · {cur.rewards.tier}</p>
{/* The sentence under the bar says the same, so the bar itself is hidden from screen readers. */}
<div aria-hidden className="mt-4 h-1.5 overflow-hidden rounded-full bg-current/20">
<div className="h-full origin-left rounded-full bg-current transition-transform duration-700 motion-reduce:transition-none" style={{ transform: `scaleX(${progress})` }} />
</div>
<p className="mt-2 text-xs opacity-80">{l.toNext(cur.rewards.next, cur.rewards.nextTier)}</p>
<ul className="mt-4 space-y-1 text-sm">
{cur.rewards.perks.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</DataTile>
<DataTile title={l.spending} className="@3xl:col-span-6 @6xl:col-span-4">
<Donut data={cur.spending} label={`${l.spending}, ${cur.code}`} format={fmt} />
</DataTile>
<DataTile title={l.allocation} className="@3xl:col-span-6 @6xl:col-span-12">
<ul className="grid gap-4 @3xl:grid-cols-2">
{cur.allocation.map((a) => (
<li key={a.id}>
<TileFact label={a.label} aside={<span className="tabular-nums">{Math.round(a.share * 100)}%</span>}>
<span aria-hidden className="mt-2 block h-2 w-full overflow-hidden rounded-full bg-muted">
<span className="block h-full origin-left rounded-full bg-chart-1 transition-transform duration-700 motion-reduce:transition-none" style={{ transform: `scaleX(${a.share})` }} />
</span>
</TileFact>
</li>
))}
</ul>
</DataTile>
</div>
</div>
)
}
export default CurrencyWalletcurrency-wallet-demo.tsx
import { CurrencyWallet, type WalletCurrency } from '@/components/currency-wallet'
// Example data for the demo, not real accounts or rates.
const data: WalletCurrency[] = [
{
code: 'EUR',
balance: 4210.5,
change: 2.4,
spending: [
{ label: 'Groceries', value: 310.2 },
{ label: 'Travel', value: 420 },
{ label: 'Eating out', value: 180.4 },
{ label: 'Bills', value: 255 },
],
allocation: [
{ id: 'spend', label: 'Spending', share: 0.4 },
{ id: 'save', label: 'Savings', share: 0.45 },
{ id: 'trip', label: 'Trip pot', share: 0.15 },
],
rewards: { points: 3820, next: 1180, tier: 'Silver', nextTier: 'Gold', perks: ['Free ATM withdrawals', 'Travel insurance'] },
},
{
code: 'USD',
balance: 1380,
change: -0.8,
spending: [
{ label: 'Travel', value: 540 },
{ label: 'Shopping', value: 210.5 },
{ label: 'Eating out', value: 96 },
],
allocation: [
{ id: 'spend', label: 'Spending', share: 0.7 },
{ id: 'save', label: 'Savings', share: 0.3 },
],
rewards: { points: 640, next: 360, tier: 'Silver', nextTier: 'Gold', perks: ['No fee on card payments abroad'] },
},
{
code: 'DKK',
balance: 18240,
change: 1.1,
spending: [
{ label: 'Groceries', value: 2410 },
{ label: 'Transport', value: 980 },
{ label: 'Bills', value: 3120 },
],
allocation: [
{ id: 'spend', label: 'Spending', share: 0.55 },
{ id: 'save', label: 'Savings', share: 0.45 },
],
rewards: { points: 1210, next: 790, tier: 'Silver', nextTier: 'Gold', perks: ['Free transfers between pots'] },
},
]
export default function CurrencyWalletDemo() {
return <CurrencyWallet data={data} />
}HTML
The whole page as one file with all CSS built in and no JavaScript. Toggles, tabs and filters work with HTML and CSS alone.
The colour in your own app
If you already have shadcn set up, these variables give the same colour. Light in :root, dark in .dark.
:root {
--primary: oklch(0.52 0.2 262);
--primary-foreground: oklch(0.99 0 0);
--accent: oklch(0.95 0.034 262);
--accent-foreground: oklch(0.36 0.140 262);
--ring: oklch(0.52 0.2 262);
}
.dark {
--primary: oklch(0.74 0.150 262);
--primary-foreground: oklch(0.18 0.024 262);
--accent: oklch(0.29 0.050 262);
--accent-foreground: oklch(0.9 0.050 262);
--ring: oklch(0.74 0.150 262);
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/currency-wallet.json