Skip to content
UI Kit
Accent color

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

3 variants

Variants

Analytics

Four KPIs, a revenue line and bookings per weekday.

Finance

Balance line, a ring that fills segment by segment and the latest payments sliding in.

Live

After the build the line keeps streaming, the bars breathe and a live dot pings. Pause it with the button.

Details

What Dashboard build brings along, read from the registry and the source.

Category
Animations
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

Props

The DashboardBuildProps interface from components/ui/dashboard-build.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.

  • titlerequired
    string
  • subtitleoptional
    string
  • kpisrequired
    BuildKpi[]
  • seriesrequired
    number[]

    The main line, any length

  • seriesLabeloptional
    string
  • axisoptional
    string[]

    Labels under the line, e.g. weekdays

  • barsoptional
    number[]

    A bar chart in the side card

  • barsLabeloptional
    string
  • barAxisoptional
    string[]
  • segmentsoptional
    BuildSegment[]

    A ring chart in the side card instead of bars

  • segmentsLabeloptional
    string
  • rowsoptional
    BuildRow[]

    A list under the charts

  • rowsLabeloptional
    string
  • liveoptional
    boolean

    Keep the line streaming after the build, with a live dot. Let the series end where it starts, then the loop has no seam.

  • liveLabeloptional
    string

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
components/ui/dashboard-build.tsx
'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.

Often searched

Jump to

Copied to the clipboard