Vorlage · React · HTML
Spend control
Firmenausgaben in Tiles: eine Symbolleiste, eine dunkle Upgrade-Kachel, virtuelle Karten mit Limits, Ausgabenbalken und ein Blasendiagramm der Händler.
Vorschau
React
Installiere die Vorlage mit shadcn. Sie landet als normale Komponenten in deinem Projekt, die du selbst anpasst.
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
Die ganze Seite als eine Datei mit eingebautem CSS und ohne JavaScript. Umschalter, Tabs und Filter funktionieren allein mit HTML und CSS.
Die Farbe in deiner eigenen App
Wenn shadcn bereits eingerichtet ist, ergeben diese Variablen dieselbe Farbe. Hell in :root, dunkel 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);
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/spend-control.json