Mikkel Manniche
Dansk English Deutsch

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.json
npx shadcn@latest add @manniche/currency-wallet
currency-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 CurrencyWallet
currency-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.

Open in a new tab

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

Dependencies

    Files

    • currency-wallet.tsx
    • currency-wallet.html

    Manniche Lab · open source

    Free tools for AI agents and web development.