Sextant
← Playground

Live KPI Ticker

free

An 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-ticker
State
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%).

Acme · live metrics — live KPI values and moves
KPIValueChange
MRR$4.8M+18.2%
Headcount210+6.5%
Churn2.5%-14.0%
NPS62+8.0%
p99 latency185 ms-11.0%
Support queue42-28.0%
Monthly signups1.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>
  );
}