Template · React · HTML
Spend control
Company spend dashboard in Tiles: an icon rail, an inverted upsell tile, virtual cards with limits, spending bars and a bubble chart of merchants.
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/spend-control.jsonnpx shadcn@latest add @manniche/spend-controlspend-control.tsx
// A spend-control screen: icon rail, an upsell panel, virtual cards, the spending bars and a bubble chart of merchants.
// Built from the Tiles primitives. All figures are example data passed in as props.
import { type LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BarChart, type BarPoint } from '@/registry/manniche/bar-chart/bar-chart'
import { BubbleChart, type BubbleDatum } from '@/registry/manniche/bubble-chart/bubble-chart'
import { BigNumber, DeltaPill } from '@/registry/manniche/chart-kit/chart-kit'
import { DataTile, TileFact } from '@/registry/manniche/data-tile/data-tile'
export type SpendCard = { id: string; name: string; last4: string; spent: number; limit: number; frozen?: boolean }
export type SpendRailItem = { id: string; label: string; icon: LucideIcon; current?: boolean }
export type SpendControlData = {
rail: SpendRailItem[]
cards: SpendCard[]
spending: BarPoint[]
spendingTotal: number
spendingChange: number
merchants: BubbleDatum[]
upsell: { title: string; text: string; action: string }
}
export type SpendControlLabels = {
title?: string
cards?: string
spending?: string
merchants?: string
limit?: string
frozen?: string
note?: string
navLabel?: string
}
export type SpendControlProps = {
data: SpendControlData
labels?: SpendControlLabels
now?: Date
currency?: string
onUpsell?: () => void
className?: string
}
const L: Required<SpendControlLabels> = {
title: 'Spend control',
cards: 'Virtual cards',
spending: 'Spending, last 7 days',
merchants: 'Merchants by visits and average spend',
limit: 'of',
frozen: 'Frozen',
note: 'Example data.',
navLabel: 'Sections',
}
export function SpendControl({ data, labels, now, currency = 'EUR', onUpsell, className }: SpendControlProps) {
const t = { ...L, ...labels }
const money = { currency, decimals: 0 }
const date = now?.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })
return (
<div className={cn('@container w-full rounded-2xl bg-background p-3 text-foreground', className)}>
{/* Container queries match an ancestor, so the row/column switch sits one level in. */}
<div className="flex flex-col gap-3 @sm:flex-row">
<nav aria-label={t.navLabel} className="flex shrink-0 items-center gap-1 self-start overflow-x-auto rounded-xl border border-border bg-card p-1.5 @sm:flex-col @sm:self-stretch">
{data.rail.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
aria-label={item.label}
title={item.label}
aria-current={item.current ? 'page' : undefined}
className={cn(
'grid size-10 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
item.current && 'bg-foreground text-background hover:bg-foreground hover:text-background',
)}
>
<item.icon className="size-4" aria-hidden />
</a>
))}
</nav>
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 @2xl:grid-cols-6">
<header className="@2xl:col-span-6">
{date && <p className="text-xs text-muted-foreground">{date}</p>}
<h2 className="text-lg font-semibold tracking-tight">{t.title}</h2>
</header>
<div className="@2xl:col-span-4">
<DataTile className="h-full" title={t.spending} footer={t.note}>
<TileFact aside={<DeltaPill value={data.spendingChange} goodWhen="down" />}>
<BigNumber value={data.spendingTotal} format={money} size="xl" />
</TileFact>
<BarChart data={data.spending} label={t.spending} format={money} />
</DataTile>
</div>
<div className="@2xl:col-span-2">
<DataTile className="h-full" title={data.upsell.title} inverted>
<p className="text-sm opacity-80">{data.upsell.text}</p>
<button
type="button"
onClick={onUpsell}
className="mt-4 rounded-full bg-foreground px-4 py-2 text-sm font-medium text-background transition-transform active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{data.upsell.action}
</button>
</DataTile>
</div>
<div className="@2xl:col-span-2">
<DataTile className="h-full" title={t.cards}>
<ul className="flex flex-col gap-3">
{data.cards.map((c) => {
const pct = c.limit > 0 ? Math.min(100, Math.round((c.spent / c.limit) * 100)) : 0
return (
<li key={c.id} className={cn('flex flex-col gap-1.5', c.frozen && 'opacity-60')}>
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="truncate font-medium">{c.name}</span>
<span className="tabular-nums text-xs text-muted-foreground">
{c.frozen ? t.frozen : `•••• ${c.last4}`}
</span>
</div>
<div
role="progressbar"
aria-label={c.name}
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 overflow-hidden rounded-full bg-muted"
>
<div className="h-full rounded-full bg-foreground" style={{ width: `${pct}%` }} />
</div>
<p className="text-xs tabular-nums text-muted-foreground">
{c.spent.toLocaleString('en-GB', { style: 'currency', currency, maximumFractionDigits: 0 })} {t.limit}{' '}
{c.limit.toLocaleString('en-GB', { style: 'currency', currency, maximumFractionDigits: 0 })}
</p>
</li>
)
})}
</ul>
</DataTile>
</div>
<div className="@2xl:col-span-4">
<DataTile className="h-full" title={t.merchants}>
<BubbleChart data={data.merchants} label={t.merchants} format={money} yFormat={money} />
</DataTile>
</div>
</div>
</div>
</div>
)
}spend-control-demo.tsx
import { CreditCard, Home, PieChart, Settings, Users, Wallet } from 'lucide-react'
import { SpendControl, type SpendControlData } from '@/components/spend-control'
const sampleRail: SpendControlData['rail'] = [
{ id: 'home', label: 'Home', icon: Home, current: true },
{ id: 'cards', label: 'Cards', icon: CreditCard },
{ id: 'wallet', label: 'Wallet', icon: Wallet },
{ id: 'reports', label: 'Reports', icon: PieChart },
{ id: 'team', label: 'Team', icon: Users },
{ id: 'settings', label: 'Settings', icon: Settings },
]
// Example data only. Names and figures are invented.
const data: SpendControlData = {
rail: sampleRail,
cards: [
{ id: 'c1', name: 'Team lunches', last4: '4401', spent: 312, limit: 500 },
{ id: 'c2', name: 'Software', last4: '8120', spent: 689, limit: 800 },
{ id: 'c3', name: 'Travel', last4: '2257', spent: 40, limit: 1500, frozen: true },
],
spending: [
{ label: 'Wed', title: 'Wednesday 30 Sep', value: 95 },
{ label: 'Thu', title: 'Thursday 1 Oct', value: 340 },
{ label: 'Fri', title: 'Friday 2 Oct', value: 180 },
{ label: 'Sat', title: 'Saturday 3 Oct', value: 60 },
{ label: 'Sun', title: 'Sunday 4 Oct', value: 36 },
{ label: 'Mon', title: 'Monday 5 Oct', value: 120 },
{ label: 'Tue', title: 'Tuesday 6 Oct', value: 210 },
],
spendingTotal: 1041,
spendingChange: -4.2,
merchants: [
{ label: 'Example Cafe', value: 420, x: 12, y: 18, group: 'Food' },
{ label: 'Sample Cloud', value: 689, x: 2, y: 120, group: 'Software' },
{ label: 'Demo Rail', value: 240, x: 3, y: 64, group: 'Travel' },
{ label: 'Test Supplies', value: 150, x: 6, y: 35, group: 'Office' },
{ label: 'Mock Market', value: 310, x: 9, y: 28, group: 'Food' },
],
upsell: { title: 'More cards', text: 'Example panel: add cards for every team.', action: 'Add a card' },
}
export default function SpendControlDemo() {
return <SpendControl data={data} now={new Date('2026-10-06T10:00:00')} />
}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/spend-control.json