Dashboard build
A dashboard that puts itself together in view: cards rise, numbers count, the line draws, bars grow, the ring fills. "live" keeps it streaming.
One of 4 components in Animations
Details
What Dashboard build brings along, read from the registry and the source.
- Category
- Animations
- Area
- Visuals
- Variants
- 3
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/dashboard-build.tsx
- Brings along
- utilskitnumber-tickermotion-scene
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DashboardBuildProps interface from components/ui/dashboard-build.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.
titlerequiredstringsubtitleoptionalstringkpisrequiredBuildKpi[]seriesrequirednumber[]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
Accessibility
Read from the source of the component.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 298 lines
'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;
}Frequent questions
How do I install Dashboard build?
With aiw add @ai-website/dashboard-build. The command puts components/ui/dashboard-build.tsx into your project, the code is yours from then on. It brings along number-ticker and motion-scene. It needs no further npm packages.
Which variants does Dashboard build have?
3 variants, Analytics, Finance and Live.
Can an AI assistant build with Dashboard build?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Dashboard build with aiw_components and install it with aiw_add @ai-website/dashboard-build.