Vorlage · React · HTML
Delivery board
Ein Projekt-Board in Tiles: Fortschritt pro Kunde, ein Tagesstreifen, der heute öffnet, Roadmap-Bahnen mit Initialen und eine kleine Assistenzkachel.
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/delivery-board.jsonnpx shadcn@latest add @manniche/delivery-boarddelivery-board.tsx
// A delivery board: client progress, a date strip (opens on `now` when it is in the strip), roadmap lanes with initials avatars and a small assistant tile.
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { DataTile } from '@/registry/manniche/data-tile/data-tile'
export type BoardClient = { id: string; name: string; progress: number }
export type BoardItem = { id: string; title: string; owners: string[] }
export type BoardLane = { id: string; title: string; items: BoardItem[] }
export type DeliveryBoardData = {
clients: { title: string; items: BoardClient[] }
/** ISO dates "YYYY-MM-DD", one per chip. */
days: string[]
roadmap: { title: string; lanes: BoardLane[] }
assistant: { title: string; text: string; action: string }
}
export type DeliveryBoardLabels = { note?: string; daysLabel?: string }
export type DeliveryBoardProps = {
data: DeliveryBoardData
labels?: DeliveryBoardLabels
now?: Date
onAssistant?: () => void
className?: string
}
/** Local calendar date as "YYYY-MM-DD". */
const isoDay = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const initials = (n: string) => n.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase()
export function DeliveryBoard({ data, labels, now, onAssistant, className }: DeliveryBoardProps) {
const note = labels?.note ?? 'Example data.'
const today = now && isoDay(now)
const [day, setDay] = useState(today && data.days.includes(today) ? today : data.days[0])
return (
<div className={cn('@container w-full rounded-2xl bg-background p-3 text-foreground', className)}>
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-6">
<div className="@2xl:col-span-4">
<DataTile className="h-full" title={data.clients.title} footer={note}>
<ul className="flex flex-col gap-3">
{data.clients.items.map((c) => (
<li key={c.id} className="flex flex-col gap-1.5">
<div className="flex justify-between text-sm">
<span className="font-medium">{c.name}</span>
<span className="tabular-nums text-muted-foreground">{c.progress}%</span>
</div>
<div role="progressbar" aria-label={c.name} aria-valuenow={c.progress} 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: `${c.progress}%` }} />
</div>
</li>
))}
</ul>
</DataTile>
</div>
<div className="@2xl:col-span-2">
<DataTile className="h-full" title={data.assistant.title} inverted>
<p className="text-sm opacity-80">{data.assistant.text}</p>
<button type="button" onClick={onAssistant} 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.assistant.action}
</button>
</DataTile>
</div>
<div className="@2xl:col-span-6">
<div role="group" aria-label={labels?.daysLabel ?? 'Day'} className="flex gap-1 overflow-x-auto rounded-full border border-border bg-card p-1">
{data.days.map((d) => {
const date = new Date(`${d}T12:00:00Z`)
return (
<button
key={d}
type="button"
aria-pressed={d === day}
aria-current={d === today ? 'date' : undefined}
onClick={() => setDay(d)}
className={cn('relative flex min-w-11 flex-1 flex-col items-center rounded-full px-1 py-1 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring', d === day && 'bg-foreground text-background hover:text-background')}
>
<span>{date.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' })}</span>
<span className="text-sm font-medium tabular-nums">{date.getUTCDate()}</span>
{d === today && <span aria-hidden className="absolute bottom-0.5 size-1 rounded-full bg-current" />}
</button>
)
})}
</div>
</div>
<div className="@2xl:col-span-6">
<DataTile className="h-full" title={data.roadmap.title} footer={note}>
<div className="grid grid-cols-1 gap-4 @xl:grid-cols-3">
{data.roadmap.lanes.map((lane) => (
<section key={lane.id} aria-label={lane.title} className="flex flex-col gap-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{lane.title}</h3>
<ul className="flex flex-col gap-2">
{lane.items.map((it) => (
<li key={it.id} className="flex items-center justify-between gap-2 rounded-xl border border-border bg-card px-3 py-2 text-sm">
<span className="min-w-0 truncate">{it.title}</span>
<span className="flex -space-x-1.5">
{it.owners.map((o) => (
<span key={o} title={o} role="img" aria-label={o} className="grid size-6 place-items-center rounded-full border border-card bg-muted text-[10px] font-medium">
{initials(o)}
</span>
))}
</span>
</li>
))}
</ul>
</section>
))}
</div>
</DataTile>
</div>
</div>
</div>
)
}delivery-board-demo.tsx
import { DeliveryBoard, type DeliveryBoardData } from '@/components/delivery-board'
// Example data only. All names are invented.
const data: DeliveryBoardData = {
clients: {
title: 'Client progress',
items: [
{ id: 'a', name: 'Sample Studio', progress: 72 },
{ id: 'b', name: 'Demo Works', progress: 45 },
{ id: 'c', name: 'Example Labs', progress: 18 },
],
},
days: ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-12'],
roadmap: {
title: 'Roadmap',
lanes: [
{ id: 'next', title: 'Next', items: [{ id: 'i1', title: 'Sample brief', owners: ['Alex Example'] }, { id: 'i2', title: 'Demo kickoff', owners: ['Sam Sample', 'Robin Demo'] }] },
{ id: 'doing', title: 'In progress', items: [{ id: 'i3', title: 'Example prototype', owners: ['Robin Demo'] }] },
{ id: 'done', title: 'Delivered', items: [{ id: 'i4', title: 'Test handover', owners: ['Alex Example', 'Sam Sample'] }] },
],
},
assistant: { title: 'Assistant', text: 'Example tile: ask for a status summary.', action: 'Summarise' },
}
export default function DeliveryBoardDemo() {
return <DeliveryBoard 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/delivery-board.json