Dashboard-Aufbau
Ein Dashboard, das sich im Blickfeld zusammensetzt: Karten steigen auf, Zahlen zählen, die Linie zeichnet sich, Balken wachsen, der Ring füllt sich. "live" hält es im Fluss.
Eine von 4 Components in Animations
Details
Was Dashboard-Aufbau mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Animations
- Bereich
- Visuals
- Variants
- 3
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/dashboard-build.tsx
- Bringt mit
- utilskitnumber-tickermotion-scene
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle DashboardBuildProps aus components/ui/dashboard-build.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von SceneMotionProps an.
titlePflichtstringsubtitleoptionalstringkpisPflichtBuildKpi[]seriesPflichtnumber[]The main line, any length
seriesLabeloptionalstringaxisoptionalstring[]Labels under the line, e.g. weekdays
barsoptionalnumber[]A bar chart in the side card
barsLabeloptionalstringbarAxisoptionalstring[]segmentsoptionalBuildSegment[]A ring chart in the side card instead of bars
segmentsLabeloptionalstringrowsoptionalBuildRow[]A list under the charts
rowsLabeloptionalstringliveoptionalbooleanKeep the line streaming after the build, with a live dot. Let the series end where it starts, then the loop has no seam.
liveLabeloptionalstring
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 298 Zeilen
'use client';
import { useEffect } from 'react';
import { cn, round, useLocale } from '@/lib/kit';
import { NumberTicker } from '@/components/ui/number-ticker';
import { PlayToggle, SCENE_CSS, keyframes, useSceneId, useScenePlay, type SceneMotionProps } from '@/components/ui/motion-scene';
/*
* A dashboard that builds itself: the cards rise one after another, the
* numbers count, the line draws, the bars grow and the ring fills, in that
* order, like a report being put together. "live" keeps a stream running
* afterwards. The server renders the finished dashboard.
*/
export interface BuildKpi {
label: string;
value: number;
/** Intl.NumberFormat options, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 } */
format?: Intl.NumberFormatOptions;
/** A short change line, e.g. "+12.4%" */
delta?: string;
/** The change is bad (shown quiet instead of green) */
down?: boolean;
}
export interface BuildSegment {
label: string;
value: number;
}
export interface BuildRow {
label: string;
value: string;
meta?: string;
}
export interface DashboardBuildProps extends SceneMotionProps {
title: string;
subtitle?: string;
kpis: BuildKpi[];
/** The main line, any length */
series: number[];
seriesLabel?: string;
/** Labels under the line, e.g. weekdays */
axis?: string[];
/** A bar chart in the side card */
bars?: number[];
barsLabel?: string;
barAxis?: string[];
/** A ring chart in the side card instead of bars */
segments?: BuildSegment[];
segmentsLabel?: string;
/** A list under the charts */
rows?: BuildRow[];
rowsLabel?: string;
/** Keep the line streaming after the build, with a live dot. Let the series end where it starts, then the loop has no seam. */
live?: boolean;
liveLabel?: string;
className?: string;
}
const W = 600;
const H = 200;
function linePath(values: number[], w = W, h = H, pad = 12) {
const hi = Math.max(...values);
const lo = Math.min(...values);
const max = hi + (hi - lo) * 0.12;
const min = Math.max(0, lo - (hi - lo) * 0.45);
const pts = values.map((v, i) => [round(8 + (i / (values.length - 1)) * (w - 16)), round(h - pad - ((v - min) / (max - min)) * (h - pad * 2))] as const);
// smooth with a simple cardinal curve
let d = `M${pts[0][0]} ${pts[0][1]}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const c1 = [round(p1[0] + (p2[0] - p0[0]) / 6), round(p1[1] + (p2[1] - p0[1]) / 6)];
const c2 = [round(p2[0] - (p3[0] - p1[0]) / 6), round(p2[1] - (p3[1] - p1[1]) / 6)];
d += ` C${c1[0]} ${c1[1]} ${c2[0]} ${c2[1]} ${p2[0]} ${p2[1]}`;
}
return { d, last: pts[pts.length - 1] };
}
/**
* <DashboardBuild title="Overview" kpis={[{ label: 'Revenue', value: 48210, format: { style: 'currency', currency: 'EUR' } }]} series={[…]} bars={[…]} />
*/
export function DashboardBuild({ title, subtitle, kpis, series, seriesLabel, axis, bars, barsLabel, barAxis, segments, segmentsLabel, rows, rowsLabel, live = false, liveLabel = 'Live', speed = 1, autoPlay = 'in-view', loop = false, controls = true, className }: DashboardBuildProps) {
const sid = useSceneId('kvdb');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const locale = useLocale();
const sp = Math.max(0.1, speed);
const s = (x: number) => round(x / sp, 3);
const line = linePath(series);
// stream: the same series twice side by side, shifted by one width
const stream = live ? linePath([...series, ...series.slice(1)], W * 2 - W / (series.length - 1), H) : null;
const total = segments?.reduce((a, b) => a + b.value, 0) ?? 0;
const R = 54;
let acc = 0;
const T = 6; // loop cycle in seconds at speed 1
const css = `${SCENE_CSS}
${keyframes(`${sid}-rise`, [[0, 'opacity:0;transform:translateY(14px) scale(.985)'], [100, 'opacity:1;transform:none']])}
${keyframes(`${sid}-draw`, [[0, 'stroke-dashoffset:1'], [100, 'stroke-dashoffset:0']])}
${keyframes(`${sid}-fade`, [[0, 'opacity:0'], [100, 'opacity:1']])}
${keyframes(`${sid}-grow`, [[0, 'transform:scaleY(0)'], [100, 'transform:scaleY(1)']])}
${keyframes(`${sid}-seg`, [[0, 'stroke-dashoffset:var(--len)'], [100, 'stroke-dashoffset:0']])}
${keyframes(`${sid}-stream`, [[0, 'transform:translateX(0)'], [100, `transform:translateX(-50%)`]])}
${keyframes(`${sid}-ping`, [[0, 'transform:scale(1);opacity:.6'], [100, 'transform:scale(3);opacity:0']])}
${keyframes(`${sid}-breathe`, [[0, 'transform:scaleY(1)'], [50, 'transform:scaleY(var(--b))'], [100, 'transform:scaleY(1)']])}
.${sid}:not([data-kv="off"]) .${sid}-rise { animation: ${sid}-rise ${s(0.7)}s var(--ease-kit) var(--d) 1 both; }
.${sid}:not([data-kv="off"]) .${sid}-draw { animation: ${sid}-draw ${s(1.4)}s cubic-bezier(.45,0,.2,1) var(--d) 1 both; }
.${sid}:not([data-kv="off"]) .${sid}-fade { animation: ${sid}-fade ${s(0.9)}s ease-out var(--d) 1 both; }
.${sid}:not([data-kv="off"]) .${sid}-grow { animation: ${sid}-grow ${s(0.8)}s cubic-bezier(.3,1.3,.5,1) var(--d) 1 both; }
.${sid}:not([data-kv="off"]) .${sid}-seg { animation: ${sid}-seg ${s(0.8)}s cubic-bezier(.45,0,.2,1) var(--d) 1 both; }
.${sid}:not([data-kv="off"]) .${sid}-stream { animation: ${sid}-stream ${s(series.length * 0.9)}s linear ${s(2.4)}s infinite; }
.${sid}:not([data-kv="off"]) .${sid}-ping { animation: ${sid}-ping ${s(1.8)}s ease-out infinite; }
.${sid}:not([data-kv="off"]) .${sid}-breathe { animation: ${sid}-grow ${s(0.8)}s cubic-bezier(.3,1.3,.5,1) var(--d) 1 both${live ? `, ${sid}-breathe ${s(2.6)}s ease-in-out calc(var(--d) + ${s(1)}s) infinite` : ''}; }`;
const d = (x: number) => ({ '--d': `${s(x)}s` }) as React.CSSProperties;
const card = `${sid}-rise rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]`;
return (
<div className={cn('relative w-full', className)}>
<div ref={play.ref} data-kv={play.state} className={cn(sid, 'w-full rounded-2xl border border-border bg-surface p-3 text-left text-foreground shadow-[var(--highlight),var(--shadow-lg)] sm:p-5')}>
<style dangerouslySetInnerHTML={{ __html: css }} />
<div key={`${play.cycle}`} className="flex flex-col gap-3">
<div className={`${sid}-rise flex items-end justify-between gap-3 px-1`} style={d(0)}>
<div>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
<h3 className="text-[19px] font-semibold tracking-[-0.02em]">{title}</h3>
</div>
{live && (
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-2.5 py-1 text-xs font-medium">
<span className="relative size-2">
<span className={`${sid}-ping absolute inset-0 rounded-full bg-success`} />
<span className="absolute inset-0 rounded-full bg-success" />
</span>
{liveLabel}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{kpis.map((k, i) => (
<div key={k.label} className={cn(card, 'px-4 py-3.5')} style={d(0.12 + i * 0.08)}>
<p className="text-xs text-muted-foreground">{k.label}</p>
<p className="mt-1 text-[22px] font-semibold tracking-[-0.03em]">
<NumberTicker value={k.value} format={k.format} duration={1400 / sp} delay={(250 + i * 90) / sp} />
</p>
{k.delta && <p className={cn('text-xs font-medium', k.down ? 'text-muted-foreground' : 'text-success-foreground')}>{k.delta}</p>}
</div>
))}
</div>
<div className={cn('grid grid-cols-1 gap-3', (bars || segments) && 'lg:grid-cols-[1.7fr_1fr]')}>
<div className={cn(card, 'flex flex-col p-4')} style={d(0.45)}>
{seriesLabel && <p className="text-sm font-semibold">{seriesLabel}</p>}
<div className="relative mt-3 h-[180px] overflow-hidden sm:h-[200px]">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="absolute inset-0 size-full" aria-hidden>
{[0.25, 0.5, 0.75].map((y) => <line key={y} x1="0" x2={W} y1={H * y} y2={H * y} stroke="var(--border)" vectorEffect="non-scaling-stroke" />)}
</svg>
{stream ? (
<div className={`${sid}-stream absolute inset-y-0 left-0 w-[200%]`}>
<svg viewBox={`0 0 ${W * 2} ${H}`} preserveAspectRatio="none" className="absolute inset-0 size-full" aria-hidden>
<defs>
<linearGradient id={`${sid}-g`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor="var(--primary)" stopOpacity="0.26" />
<stop offset="1" stopColor="var(--primary)" stopOpacity="0" />
</linearGradient>
</defs>
<path className={`${sid}-fade`} style={d(1.1)} d={`${stream.d} L${stream.last[0]} ${H} L0 ${H} Z`} fill={`url(#${sid}-g)`} />
<path className={`${sid}-draw`} style={d(0.6)} d={stream.d} pathLength={1} strokeDasharray="1 1" fill="none" stroke="var(--primary)" strokeWidth="2.2" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
</svg>
</div>
) : (
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="absolute inset-0 size-full" aria-hidden>
<defs>
<linearGradient id={`${sid}-g`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor="var(--primary)" stopOpacity="0.26" />
<stop offset="1" stopColor="var(--primary)" stopOpacity="0" />
</linearGradient>
</defs>
<path className={`${sid}-fade`} style={d(1.1)} d={`${line.d} L${W} ${H} L0 ${H} Z`} fill={`url(#${sid}-g)`} />
<path className={`${sid}-draw`} style={d(0.6)} d={line.d} pathLength={1} strokeDasharray="1 1" fill="none" stroke="var(--primary)" strokeWidth="2.2" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
</svg>
)}
{!stream && (
<span
className={`${sid}-fade absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_22%,transparent)]`}
style={{ left: `${round((line.last[0] / W) * 100, 2)}%`, top: `${round((line.last[1] / H) * 100, 2)}%`, ...d(1.9) }}
/>
)}
</div>
{axis && (
<div className="mt-2 flex justify-between text-[11px] text-subtle-foreground">
{axis.map((a, i) => <span key={i}>{a}</span>)}
</div>
)}
</div>
{bars && (
<div className={cn(card, 'flex flex-col p-4')} style={d(0.6)}>
{barsLabel && <p className="text-sm font-semibold">{barsLabel}</p>}
<div className="mt-3 flex h-[180px] items-end gap-[6%] sm:h-[200px]">
{bars.map((b, i) => {
const max = Math.max(...bars);
return (
<span key={i} className="flex h-full flex-1 flex-col justify-end">
<span
className={cn(`${sid}-breathe block origin-bottom rounded-t-md`, i === bars.length - 1 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_32%,var(--muted))]')}
style={{ height: `${round((b / max) * 100, 2)}%`, '--b': `${round(0.86 + ((i * 37) % 23) / 100, 2)}`, ...d(0.9 + i * 0.07) } as React.CSSProperties}
/>
</span>
);
})}
</div>
{barAxis && (
<div className="mt-2 flex gap-[6%] text-center text-[11px] text-subtle-foreground">
{barAxis.map((a, i) => <span key={i} className="flex-1">{a}</span>)}
</div>
)}
</div>
)}
{segments && !bars && (
<div className={cn(card, 'flex flex-col p-4')} style={d(0.6)}>
{segmentsLabel && <p className="text-sm font-semibold">{segmentsLabel}</p>}
<div className="mt-3 flex flex-1 flex-wrap items-center gap-5">
<svg viewBox="0 0 140 140" className="size-[140px] shrink-0 -rotate-90" aria-hidden>
<circle cx="70" cy="70" r={R} fill="none" stroke="var(--track)" strokeWidth="16" />
{segments.map((sg, i) => {
const c = 2 * Math.PI * R;
const len = round((sg.value / total) * c - 2, 2);
const off = round((acc / total) * c, 2);
acc += sg.value;
return (
<circle
key={sg.label}
className={`${sid}-seg`}
cx="70"
cy="70"
r={R}
fill="none"
stroke="var(--primary)"
strokeOpacity={1 - i * 0.26}
strokeWidth="16"
strokeDasharray={`${len} ${round(c, 2)}`}
strokeDashoffset={0}
transform={`rotate(${round((off / c) * 360, 2)} 70 70)`}
style={{ '--len': len, ...d(0.9 + i * 0.3) } as React.CSSProperties}
/>
);
})}
</svg>
<ul className="min-w-0 flex-1 space-y-2.5">
{segments.map((sg, i) => (
<li key={sg.label} className={`${sid}-rise flex items-center gap-2 text-[13px]`} style={d(1 + i * 0.3)}>
<span className="size-2.5 rounded-[3px] bg-primary" style={{ opacity: 1 - i * 0.26 }} />
<span className="min-w-0 flex-1 truncate">{sg.label}</span>
<span className="tabular-nums text-muted-foreground">{new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'percent' }).format(sg.value / total)}</span>
</li>
))}
</ul>
</div>
</div>
)}
</div>
{rows && (
<div className={cn(card, 'overflow-hidden')} style={d(1)}>
{rowsLabel && <p className="px-4 pt-3.5 text-sm font-semibold">{rowsLabel}</p>}
<ul className="mt-1.5 divide-y divide-border">
{rows.map((r, i) => (
<li key={r.label} className={`${sid}-rise flex items-center gap-3 px-4 py-2.5 text-[13px]`} style={d(1.2 + i * 0.1)}>
<span className="min-w-0 flex-1 truncate font-medium">{r.label}</span>
{r.meta && <span className="text-muted-foreground max-sm:hidden">{r.meta}</span>}
<span className="tabular-nums">{r.value}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
{controls && (loop || live) && (
<div className="mt-2 flex justify-end">
<PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
</div>
)}
{loop && <LoopTimer cycle={T / sp} onCycle={play.replay} active={play.state === 'on'} />}
</div>
);
}
/** Replays the build every few seconds while the scene plays */
function LoopTimer({ cycle, onCycle, active }: { cycle: number; onCycle: () => void; active: boolean }) {
useEffect(() => {
if (!active) return;
const t = window.setInterval(onCycle, cycle * 1000 + 2500);
return () => window.clearInterval(t);
}, [active, cycle, onCycle]);
return null;
}Häufige Fragen
Wie installiere ich Dashboard-Aufbau?
Mit aiw add @ai-website/dashboard-build. Der Befehl legt die Datei components/ui/dashboard-build.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind number-ticker und motion-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Dashboard-Aufbau?
3 Varianten, Analytics, Finanzen und Live.
Kann ein KI-Assistent Dashboard-Aufbau einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Dashboard-Aufbau mit aiw_components und installieren sie mit aiw_add @ai-website/dashboard-build.