Live KPI Ticker
freeAn endless, seamless marquee of KPIs — value, semantic-colored delta, and a sparkline each — that pauses on hover and off-screen. The 'this dashboard is alive' strip.
Live preview
npx shadcn add @sextant/chart-kpi-tickerState
Size
Mode
Acme · live metrics
1.8k
Monthly signups · +31.0%
Tracking 8 KPIs live — 5 up, 3 down; Monthly signups is moving most (+31.0%).
MRR
$4.8M
Headcount
210
Churn
2.5%
NPS
62
p99 latency
185 ms
Support queue
42
Monthly signups
1.8k
ARPU
$2.1k
MRR
$4.8M
Headcount
210
Churn
2.5%
NPS
62
p99 latency
185 ms
Support queue
42
Monthly signups
1.8k
ARPU
$2.1k
| KPI | Value | Change |
|---|---|---|
| MRR | $4.8M | +18.2% |
| Headcount | 210 | +6.5% |
| Churn | 2.5% | -14.0% |
| NPS | 62 | +8.0% |
| p99 latency | 185 ms | -11.0% |
| Support queue | 42 | -28.0% |
| Monthly signups | 1.8k | +31.0% |
| ARPU | $2.1k | +12.0% |
Acme analytics · updated every 60 sec
Source — this is the whole widget
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import {
ChartNarrative,
resolveNarrative,
type NarrativeProp,
} from "@/registry/default/chart-narrative/chart-narrative";
import {
ChartDataTable,
type ChartTable,
} from "@/registry/default/chart-a11y/chart-a11y";
import { ChartStates } from "@/registry/default/chart-states/chart-states";
import { safeData } from "@/registry/default/sextant-contracts/contracts";
import { SparkTile } from "@/registry/default/widget-kit/spark-tile";
import { StudioTarget, useStudio } from "@/registry/default/widget-kit/studio";
import { useSizePlan } from "@/registry/default/widget-kit/use-chart-size";
import { useLiveActive } from "@/registry/default/widget-kit/live";
import type { SizeProp } from "@/registry/default/widget-kit/chart-size";
import type { SizePlan } from "@/registry/default/widget-kit/size-plan";
import type {
ChartClassNames,
SeriesConfig,
} from "@/registry/default/widget-kit/contract";
import { kpiTicker, kpiTickerSchema, type KpiItem, type KpiTickerDatum } from "./data";
// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export {
kpiItemSchema,
kpiTickerSchema,
type KpiItem,
type KpiTickerDatum,
} from "./data";
import { isFavorable, tickerStats } from "./ticker";
const COLOR_PRESETS = [
{ name: "Maroon (brand)", value: "var(--chart-1)" },
{ name: "Sage", value: "var(--chart-positive)" },
{ name: "Ink", value: "var(--chart-2)" },
];
const TICKER_PLAN: SizePlan = {
lg: { shape: "full", legend: "none" },
md: { shape: "full", legend: "none" },
sm: { shape: "spark" },
mobile: { shape: "full", legend: "none", stack: true },
};
function compact(v: number): string {
const abs = Math.abs(v);
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (abs >= 1000) return `${(v / 1000).toFixed(1).replace(/\.0$/, "")}k`;
return `${Math.round(v * 100) / 100}`;
}
function formatValue(item: KpiItem): string {
if (item.unit === "%") return `${(item.value * 100).toFixed(1)}%`;
if (item.unit === "$") return `$${compact(item.value)}`;
return `${compact(item.value)}${item.unit ?? ""}`;
}
function signedPct(delta: number): string {
return `${delta >= 0 ? "+" : ""}${(delta * 100).toFixed(1)}%`;
}
/** The recolorable surface: the divider/sparkline accent (`accent`). */
export type ChartKpiTickerSeries = { accent?: SeriesConfig };
type Props = {
// ── Data ────────────────────────────────────────────────────────────────
/** The KPIs to scroll. Validated at runtime; a bad shape shows the error state. */
data?: KpiTickerDatum;
// ── Card chrome ───────────────────────────────────────────────────────────
/** Card title (also the export filename). */
label?: string;
attribution?: string;
/** Shorthand for `classNames.root` — width, ring, shadow, margins, etc. */
className?: string;
/** Targeted class overrides per region (root/header/body/narrative/footnote). */
classNames?: ChartClassNames;
/** Force a size tier, or "auto" (default) to adapt to the card's own width. */
size?: SizeProp;
// ── Customize ───────────────────────────────────────────────────────────
/** Recolor the sparklines / accent. */
series?: ChartKpiTickerSeries;
/** Seconds for one full loop. Default scales with the number of KPIs. */
durationSec?: number;
/** Replace, hide (`false`), or fully render the one-line narrative. */
narrative?: NarrativeProp;
};
export function ChartKpiTicker({
data = kpiTicker,
label,
attribution,
className,
classNames,
size = "auto",
series,
durationSec,
narrative,
}: Props) {
const studio = useStudio();
const title = studio.text("title", label ?? "Acme · live metrics", {
label: "Chart title",
});
const accent = studio.color("accent", series?.accent?.color ?? "var(--chart-2)", {
label: "Sparkline color",
presets: COLOR_PRESETS,
});
const attributionText = studio.text(
"attribution",
attribution ?? "Acme analytics · updated every 60 sec",
{ label: "Attribution" },
);
const parsed = React.useMemo(() => safeData(kpiTickerSchema, data), [data]);
const safe = parsed.ok ? parsed.data : null;
if (!parsed.ok) {
return (
<ChartStates status="error" shape="bar" error={{ message: parsed.error }}>
{null}
</ChartStates>
);
}
if (!safe || safe.items.length < 2) {
return (
<ChartStates
status="empty"
shape="bar"
empty={{
title: "KPI feed is empty",
description: "Configure at least two live metrics to populate the ticker.",
}}
>
{null}
</ChartStates>
);
}
const stats = tickerStats(safe);
const big = stats.biggest;
const autoNarrative =
`Tracking ${stats.total} KPIs live — ${stats.up} up, ${stats.down} down` +
(big ? `; ${big.label} is moving most (${signedPct(big.delta)}).` : ".");
return (
<ChartCard.Root
size={size}
className={cn(className, classNames?.root)}
exportable
exportTitle={title}
>
<TickerInner
data={safe}
title={title}
narrative={narrative}
autoNarrative={autoNarrative}
accent={accent}
durationSec={durationSec ?? Math.max(16, safe.items.length * 3.2)}
attributionText={attributionText}
classNames={classNames}
/>
</ChartCard.Root>
);
}
type InnerProps = {
data: KpiTickerDatum;
title: string;
narrative: NarrativeProp | undefined;
autoNarrative: string;
accent: string;
durationSec: number;
attributionText: string;
classNames?: ChartClassNames;
};
function TickerKeyframes() {
return (
<style
href="sx-ticker-kf"
precedence="default"
>{`@keyframes sx-ticker-scroll{from{transform:translateX(0)}to{transform:translateX(-50%)}}@keyframes sx-ticker-ping{0%{transform:scale(1);opacity:0.9}80%,100%{transform:scale(2.4);opacity:0}}`}</style>
);
}
function TickerInner({
data,
title,
narrative,
autoNarrative,
accent,
durationSec,
attributionText,
classNames,
}: InnerProps) {
const spec = useSizePlan(TICKER_PLAN);
const rootRef = React.useRef<HTMLDivElement | null>(null);
const active = useLiveActive(rootRef);
const [hover, setHover] = React.useState(false);
const stats = tickerStats(data);
const big = stats.biggest;
if (spec.shape === "spark" && big) {
return (
<div ref={rootRef}>
<SparkTile
label={big.label}
value={formatValue(big)}
chip={
narrative === false ? undefined : (
<ChartNarrative
text={
typeof narrative === "string"
? narrative
: `${signedPct(big.delta)} · ${stats.up}↑ ${stats.down}↓`
}
variant="chip"
className={classNames?.narrative}
/>
)
}
spark={<Sparkline values={big.spark ?? []} color={accent} />}
/>
</div>
);
}
const table: ChartTable = {
caption: `${title} — live KPI values and moves`,
columns: ["KPI", "Value", "Change"],
rows: data.items.map((it) => [it.label, formatValue(it), signedPct(it.delta)]),
};
const chips = [...data.items, ...data.items];
return (
<div ref={rootRef}>
<TickerKeyframes />
<ChartCard.Header className={classNames?.header}>
<ChartCard.HeaderMain>
<StudioTarget fieldKey="title" variant="inline">
<ChartCard.Label>{title}</ChartCard.Label>
</StudioTarget>
{big && <ChartCard.HeroNumber>{formatValue(big)}</ChartCard.HeroNumber>}
<p className="tabular flex items-center gap-2 text-sm text-muted-foreground">
<span
aria-hidden
className="inline-flex size-1.5 rounded-full"
style={{
backgroundColor: "var(--chart-positive)",
animation: active
? "sx-ticker-ping 1400ms cubic-bezier(0,0,0.2,1) infinite"
: undefined,
}}
/>
{big ? `${big.label} · ${signedPct(big.delta)}` : `${stats.total} KPIs`}
</p>
</ChartCard.HeaderMain>
</ChartCard.Header>
{resolveNarrative(narrative ?? autoNarrative, undefined, {
className: classNames?.narrative,
})}
<ChartCard.Body className={classNames?.body}>
{/* The marquee. Edges fade out via a mask so chips enter/exit softly. */}
<div
className="relative -mx-6 overflow-hidden py-1 group-data-[size=mobile]/chart-card:-mx-4 group-data-[size=sm]/chart-card:-mx-4"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
maskImage:
"linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent)",
WebkitMaskImage:
"linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent)",
}}
role="img"
aria-label={autoNarrative}
>
<div
className="flex w-max items-stretch"
style={{
animation: `sx-ticker-scroll ${durationSec}s linear infinite`,
animationPlayState: active && !hover ? "running" : "paused",
}}
>
{chips.map((it, i) => (
<Chip key={i} item={it} accent={accent} />
))}
</div>
</div>
{/* Screen-reader + no-JS fallback (the marquee is decorative motion). */}
<ChartDataTable table={table} />
</ChartCard.Body>
<StudioTarget fieldKey="attribution" variant="inline">
<ChartCard.Footnote className={classNames?.footnote}>
{attributionText}
</ChartCard.Footnote>
</StudioTarget>
</div>
);
}
function Chip({ item, accent }: { item: KpiItem; accent: string }) {
const favorable = isFavorable(item);
const tone =
item.delta === 0
? "text-muted-foreground"
: favorable
? "text-chart-positive"
: "text-chart-negative";
return (
<div className="flex items-center gap-3 border-r border-border/70 px-5">
<div className="space-y-0.5">
<p className="smallcaps whitespace-nowrap">{item.label}</p>
<p className="hero-num text-lg leading-none whitespace-nowrap">
{formatValue(item)}
</p>
</div>
<span className={cn("tabular text-xs font-medium", tone)}>
{item.delta >= 0 ? "▲" : "▼"} {signedPct(item.delta).replace("-", "")}
</span>
{item.spark && item.spark.length > 1 && (
<Sparkline values={item.spark} color={accent} />
)}
</div>
);
}
function Sparkline({ values, color }: { values: number[]; color: string }) {
if (values.length < 2) return null;
const W = 48;
const H = 20;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const pts = values
.map((v, i) => `${(i / (values.length - 1)) * W},${H - ((v - min) / span) * H}`)
.join(" ");
return (
<svg
viewBox={`0 0 ${W} ${H}`}
width={W}
height={H}
role="presentation"
className="shrink-0"
>
<polyline
points={pts}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}