Zum Inhalt springen
UI Kit
Accent

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

3 Variants

Variants

Analytics

Vier Kennzahlen, eine Umsatzlinie und Buchungen pro Wochentag.

Finanzen

Kontolinie, ein Ring, der sich Segment für Segment füllt, und die letzten Zahlungen, die hereingleiten.

Live

Nach dem Aufbau läuft die Linie weiter, die Balken atmen und ein Live-Punkt pulsiert. Mit dem Knopf anhalten.

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

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.

  • titlePflicht
    string
  • subtitleoptional
    string
  • kpisPflicht
    BuildKpi[]
  • seriesPflicht
    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

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
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;
}

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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert