# Odometer: Dashboard KPIs

> Currency with cents, a count, a percentage and milliseconds. The range switcher makes big jumps in both directions; between them the numbers drift live while the cards are on screen, and the delta chips flip color and arrow with their sign.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/odometer-dashboard-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/odometer-dashboard-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/odometer-dashboard-demo-light-e77715ba8fda29c6.png
- Registry item: https://keyframeui.com/r/odometer-dashboard-demo.json
- Categories: number, data, motion
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/odometer-dashboard-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/odometer.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/odometer#odometer-dashboard-demo

## Files

### `registry/demos/odometer-dashboard-demo.tsx`

```tsx
"use client";

import * as React from "react";
import { motion, useInView } from "motion/react";

import { cn } from "@/lib/utils";
import { Odometer } from "@/components/ui/odometer";

type Range = "24h" | "7d" | "30d";
type Reading = { value: number; delta: number };

const ranges: { id: Range; label: string }[] = [
  { id: "24h", label: "24 hours" },
  { id: "7d", label: "7 days" },
  { id: "30d", label: "30 days" },
];

const metrics: {
  id: string;
  label: string;
  format: Intl.NumberFormatOptions;
  /** Rounds a drifting value to what the format shows. */
  round: (n: number) => number;
  /** How far one idle tick can drift, as a share of the value. */
  wobble: number;
  /** Lower is better, so a fall is shown in green. */
  invert?: boolean;
  data: Record<Range, Reading>;
}[] = [
  {
    id: "revenue",
    label: "Revenue",
    format: { style: "currency", currency: "USD", minimumFractionDigits: 2 },
    round: (n) => Math.round(n * 100) / 100,
    wobble: 0.003,
    data: {
      "24h": { value: 48210.5, delta: 0.124 },
      "7d": { value: 312904.2, delta: 0.081 },
      "30d": { value: 1284377.9, delta: 0.216 },
    },
  },
  {
    id: "users",
    label: "Active users",
    format: {},
    round: Math.round,
    wobble: 0.002,
    data: {
      "24h": { value: 12842, delta: 0.038 },
      "7d": { value: 86315, delta: 0.052 },
      "30d": { value: 341208, delta: 0.149 },
    },
  },
  {
    id: "conversion",
    label: "Conversion",
    format: { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 },
    round: (n) => Math.round(n * 10000) / 10000,
    wobble: 0.01,
    data: {
      "24h": { value: 0.0342, delta: 0.006 },
      "7d": { value: 0.0318, delta: -0.024 },
      "30d": { value: 0.0296, delta: -0.012 },
    },
  },
  {
    id: "response",
    label: "Avg. response",
    format: { style: "unit", unit: "millisecond", unitDisplay: "short", maximumFractionDigits: 0 },
    round: Math.round,
    wobble: 0.025,
    invert: true,
    data: {
      "24h": { value: 184, delta: -0.031 },
      "7d": { value: 201, delta: 0.047 },
      "30d": { value: 176, delta: -0.063 },
    },
  },
];

const deltaFormat: Intl.NumberFormatOptions = {
  style: "percent",
  signDisplay: "exceptZero",
  minimumFractionDigits: 1,
  maximumFractionDigits: 1,
};

const baseline = (range: Range) => metrics.map((m) => m.data[range]);

/** One idle tick: each reading wanders a little and is pulled back toward its range's baseline. */
function drift(readings: Reading[], range: Range, noise: number[][]): Reading[] {
  return readings.map((reading, i) => {
    const m = metrics[i];
    const base = m.data[range];
    const [a, b] = noise[i];
    return {
      value: m.round(reading.value + (base.value - reading.value) * 0.2 + base.value * m.wobble * (a * 2 - 1)),
      delta: Math.round((reading.delta + (base.delta - reading.delta) * 0.2 + 0.006 * (b * 2 - 1)) * 1000) / 1000,
    };
  });
}

// The range switcher makes big jumps; between them the numbers drift every
// couple of seconds, only while the dashboard is on screen.
export function OdometerDashboardDemo() {
  const ref = React.useRef<HTMLDivElement>(null);
  const live = useInView(ref, { amount: 0.3 });
  const [range, setRange] = React.useState<Range>("24h");
  const [readings, setReadings] = React.useState(() => baseline("24h"));

  React.useEffect(() => {
    if (!live) return;
    let timer = 0;
    const tick = () => {
      const noise = metrics.map(() => [Math.random(), Math.random()]);
      setReadings((current) => drift(current, range, noise));
      timer = window.setTimeout(tick, 1600 + Math.random() * 900);
    };
    timer = window.setTimeout(tick, 1600 + Math.random() * 900);
    return () => window.clearTimeout(timer);
  }, [live, range]);

  const choose = (next: Range) => {
    setRange(next);
    setReadings(baseline(next));
  };

  const index = ranges.findIndex((r) => r.id === range);

  return (
    <div ref={ref} className="@container w-full max-w-5xl">
      <div className="flex flex-wrap items-center justify-between gap-4">
        <div className="flex items-center gap-3">
          <h3 className="text-lg font-semibold tracking-tight text-zinc-950 dark:text-zinc-50">Overview</h3>
          <span className="inline-flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
            <span className="relative flex size-1.5">
              {live ? (
                <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
              ) : null}
              <span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
            </span>
            Live
          </span>
        </div>
        <div
          role="radiogroup"
          aria-label="Time range"
          className="relative grid grid-cols-3 rounded-lg border border-zinc-200 bg-zinc-100 p-0.5 dark:border-zinc-800 dark:bg-zinc-900"
        >
          <motion.span
            aria-hidden
            className="absolute inset-y-0.5 left-0.5 w-[calc((100%-0.25rem)/3)] rounded-md bg-white shadow-sm ring-1 shadow-zinc-950/10 ring-zinc-950/5 dark:bg-zinc-700 dark:ring-white/10"
            initial={false}
            animate={{ x: `${index * 100}%` }}
            transition={{ type: "spring", stiffness: 520, damping: 42 }}
          />
          {ranges.map((r) => (
            <button
              key={r.id}
              type="button"
              role="radio"
              aria-checked={r.id === range}
              onClick={() => choose(r.id)}
              className={cn(
                "relative min-w-12 rounded-md px-3 py-1 font-mono text-xs transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400",
                r.id === range
                  ? "text-zinc-950 dark:text-zinc-50"
                  : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200",
              )}
            >
              {r.id}
            </button>
          ))}
        </div>
      </div>

      <div className="mt-5 grid gap-3 @lg:grid-cols-2 @4xl:grid-cols-4">
        {metrics.map((m, i) => {
          const { value, delta } = readings[i];
          const good = m.invert ? delta <= 0 : delta >= 0;
          return (
            <div
              key={m.id}
              className="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm shadow-zinc-950/[0.03] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none"
            >
              <div className="flex items-center justify-between gap-3">
                <p className="text-sm text-zinc-500 dark:text-zinc-400">{m.label}</p>
                <span
                  className={cn(
                    "inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium transition-colors duration-300",
                    good
                      ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
                      : "bg-rose-500/10 text-rose-700 dark:text-rose-400",
                  )}
                >
                  <motion.svg
                    aria-hidden
                    viewBox="0 0 12 12"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.5"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    className="size-3"
                    initial={false}
                    animate={{ rotate: delta < 0 ? 180 : 0 }}
                    transition={{ type: "spring", stiffness: 300, damping: 22 }}
                  >
                    <path d="M6 9.5v-7M3 5.5l3-3 3 3" />
                  </motion.svg>
                  <Odometer value={delta} format={deltaFormat} />
                </span>
              </div>
              <p className="mt-4 text-[1.75rem] leading-none font-semibold tracking-[-0.03em] text-zinc-950 dark:text-zinc-50">
                <Odometer value={value} format={m.format} />
              </p>
              <p className="mt-3 text-xs text-zinc-500 dark:text-zinc-500">
                vs. previous {ranges[index].label}
              </p>
            </div>
          );
        })}
      </div>
    </div>
  );
}
```
