Skabelon · React · HTML
Spend control
Firmaets forbrug i Tiles: en ikonbjælke, en mørk opgraderingsflise, virtuelle kort med grænser, forbrugssøjler og en boblegraf over butikker.
Forhåndsvisning
React
Installér skabelonen med shadcn. Den lægges i dit projekt som almindelige komponenter, som du selv retter i.
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
Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.
Farven i din egen app
Har du allerede shadcn sat op, giver disse variabler samme farve. Lys i :root, mørk i .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ér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/spend-control.json