# Dashboard Header

> The top of a dashboard: three trend cards, a this-period-versus-last chart you can scrub day by day, and a goal ring that fills as sales come in.

A shadcn/ui block from the [@devl](https://devl.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/devl/dashboard-header).

- Page, with a live preview: https://shadcn.exchange/r/devl/dashboard-header
- Screenshot: https://shadcn.exchange/shot/devl/dashboard-header/light.png
- Registry item: https://devl.dev/r/dashboard-header.json
- Categories: block, Dashboards
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/devl/dashboard-header.json
```

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

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`

## Files

### `registry/devl/dashboard-header.tsx`

```tsx
"use client";

import { ArrowDownRightIcon, ArrowUpRightIcon, TargetIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { type KeyboardEvent, type PointerEvent, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";

// Stays false until mounted so the server render and the first client render match.
function useSafeReducedMotion() {
  const prefers = useReducedMotion();
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted && !!prefers;
}

export interface DashboardDay {
  label: string;
  weekday: string;
  revenue: number;
  users: number;
  orders: number;
}

export type DashboardRange = 7 | 30 | 90;

export interface DashboardHeaderProps {
  days?: DashboardDay[];
  title?: string;
  currency?: string;
  dailyGoal?: number;
  defaultRange?: DashboardRange;
  className?: string;
}

type MetricKey = "revenue" | "users" | "orders";

const metrics: { key: MetricKey; label: string; tone: string; mode: "sum" | "avg" }[] = [
  { key: "revenue", label: "Revenue", tone: "text-blue-600 dark:text-blue-400", mode: "sum" },
  { key: "users", label: "Active users", tone: "text-emerald-600 dark:text-emerald-400", mode: "avg" },
  { key: "orders", label: "Orders", tone: "text-amber-500 dark:text-amber-400", mode: "sum" },
];
const revenueTone = metrics[0].tone;
const settle = { type: "spring", stiffness: 300, damping: 34 } as const;

const ranges: { value: DashboardRange; label: string }[] = [
  { value: 7, label: "7D" },
  { value: 30, label: "30D" },
  { value: 90, label: "90D" },
];

export function sampleDashboardDays(count = 180): DashboardDay[] {
  let seed = 20261024;
  const rand = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
  const end = Date.UTC(2026, 9, 24);
  return Array.from({ length: count }, (_, i) => {
    const date = new Date(end - (count - 1 - i) * 86_400_000);
    const dow = date.getUTCDay();
    const trend = 1 + (i / count) * 0.4;
    const weekend = dow === 0 || dow === 6 ? 0.84 : 1;
    const wave = 1 + 0.1 * Math.sin(i / 9 + 1);
    const revenue = Math.round(1500 * trend * weekend * wave * (0.93 + rand() * 0.14));
    return {
      label: date.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }),
      weekday: date.toLocaleDateString("en-US", { weekday: "short", timeZone: "UTC" }),
      revenue,
      users: Math.round(640 * trend * (weekend * 0.6 + 0.4) * (0.95 + rand() * 0.1)),
      orders: Math.round(revenue / (36 + rand() * 8)),
    };
  });
}

const fallbackDays = sampleDashboardDays();

function Roll({ text, className, instant }: { text: string; className?: string; instant?: boolean }) {
  const reduce = useSafeReducedMotion();
  const chars = text.split("");
  return (
    <span className={cn("inline-flex tabular-nums leading-[1.15em]", className)}>
      <span className="sr-only">{text}</span>
      {chars.map((char, i) => {
        const key = chars.length - i;
        if (!/\d/.test(char)) {
          return (
            <span aria-hidden key={`s${key}`}>
              {char}
            </span>
          );
        }
        return (
          <span aria-hidden className="relative inline-block h-[1.15em] w-[1ch] overflow-hidden" key={`d${key}`}>
            <motion.span
              animate={{ y: `${-Number(char) * 1.15}em` }}
              className="absolute inset-x-0 top-0 flex flex-col"
              initial={false}
              transition={reduce || instant ? { duration: 0 } : settle}
            >
              {Array.from({ length: 10 }, (_, n) => (
                <span className="block h-[1.15em]" key={n}>
                  {n}
                </span>
              ))}
            </motion.span>
          </span>
        );
      })}
    </span>
  );
}

function smoothPath(points: [number, number][]) {
  if (points.length < 2) return "";
  let d = `M${points[0][0]},${points[0][1]}`;
  for (let i = 0; i < points.length - 1; i++) {
    const p0 = points[i - 1] ?? points[i];
    const p1 = points[i];
    const p2 = points[i + 1];
    const p3 = points[i + 2] ?? p2;
    const c1x = p1[0] + (p2[0] - p0[0]) / 6;
    const c1y = p1[1] + (p2[1] - p0[1]) / 6;
    const c2x = p2[0] - (p3[0] - p1[0]) / 6;
    const c2y = p2[1] - (p3[1] - p1[1]) / 6;
    d += ` C${c1x},${c1y} ${c2x},${c2y} ${p2[0]},${p2[1]}`;
  }
  return d;
}

function toPoints(values: number[], lo: number, hi: number, w: number, h: number, pad = 6): [number, number][] {
  const step = values.length > 1 ? w / (values.length - 1) : 0;
  const span = hi - lo || 1;
  return values.map((v, i) => [i * step, h - pad - ((v - lo) / span) * (h - pad * 2)]);
}

function Sparkline({ values, tone, hover }: { values: number[]; tone: string; hover: number | null }) {
  const gradientId = `spark-${useId().replace(/:/g, "")}`;
  const pts = toPoints(values, Math.min(...values) * 0.9, Math.max(...values) * 1.05, 100, 36, 3);
  const line = smoothPath(pts);
  const at = hover !== null ? pts[hover] : null;
  return (
    <div className={cn("relative h-9 w-full", tone)}>
      <svg aria-hidden className="absolute inset-0 size-full overflow-visible" preserveAspectRatio="none" viewBox="0 0 100 36">
        <defs>
          <linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
            <stop offset="0%" stopColor="currentColor" stopOpacity={0.2} />
            <stop offset="100%" stopColor="currentColor" stopOpacity={0} />
          </linearGradient>
        </defs>
        <path d={`${line} L100,36 L0,36 Z`} fill={`url(#${gradientId})`} />
        <path d={line} fill="none" stroke="currentColor" strokeWidth="1.75" vectorEffect="non-scaling-stroke" />
      </svg>
      {at && (
        <span
          className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card bg-current"
          style={{ left: `${at[0]}%`, top: `${(at[1] / 36) * 100}%` }}
        />
      )}
    </div>
  );
}

export function DashboardHeader({
  days = fallbackDays,
  title = "Overview",
  currency = "$",
  dailyGoal = 2000,
  defaultRange = 30,
  className,
}: DashboardHeaderProps) {
  const reduce = useSafeReducedMotion();
  const uid = useId().replace(/:/g, "");
  const [range, setRange] = useState<DashboardRange>(defaultRange);
  const [hover, setHover] = useState<number | null>(null);
  const plotRef = useRef<HTMLDivElement>(null);

  const span = Math.min(range, Math.floor(days.length / 2));
  const current = days.slice(-span);
  const previous = days.slice(-span * 2, -span);
  const money = (n: number) => `${currency}${Math.round(n).toLocaleString("en-US")}`;
  const plain = (n: number) => Math.round(n).toLocaleString("en-US");
  const fmt = (key: MetricKey, n: number) => (key === "revenue" ? money(n) : plain(n));
  const total = (list: DashboardDay[], key: MetricKey, mode: "sum" | "avg") => {
    const sum = list.reduce((acc, d) => acc + d[key], 0);
    return mode === "avg" ? sum / Math.max(1, list.length) : sum;
  };

  const W = 600;
  const H = 240;
  const cur = current.map((d) => d.revenue);
  const prev = previous.map((d) => d.revenue);
  const rawMax = Math.max(...cur, ...prev);
  const rawMin = Math.min(...cur, ...prev);
  const rawTick = Math.max(1, rawMax - rawMin) / 3;
  const magnitude = 10 ** Math.floor(Math.log10(rawTick));
  const tick = [1, 2, 5, 10].map((n) => n * magnitude).find((n) => n >= rawTick) ?? rawTick;
  const lo = Math.max(0, Math.floor((rawMin - tick * 0.5) / tick) * tick);
  const max = Math.ceil((rawMax + tick * 0.15) / tick) * tick;
  const yOf = (v: number) => H - 8 - ((v - lo) / (max - lo || 1)) * (H - 16);
  const curPts = toPoints(cur, lo, max, W, H, 8);
  const prevPts = toPoints(prev, lo, max, W, H, 8);
  const curLine = smoothPath(curPts);
  const prevLine = smoothPath(prevPts);
  const gridValues: number[] = [];
  for (let v = lo + tick; v <= max + 1; v += tick) gridValues.push(v);

  const hovered = hover !== null ? current[hover] : null;
  const hoveredPrev = hover !== null ? previous[hover] : null;

  const pick = (event: PointerEvent<HTMLDivElement>) => {
    const rect = plotRef.current?.getBoundingClientRect();
    if (!rect) return;
    const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
    setHover(Math.round(ratio * (current.length - 1)));
  };

  const onPlotKey = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
      event.preventDefault();
      const delta = event.key === "ArrowRight" ? 1 : -1;
      setHover((h) => Math.min(current.length - 1, Math.max(0, (h ?? (delta > 0 ? -1 : current.length)) + delta)));
    } else if (event.key === "Escape") {
      setHover(null);
    }
  };

  const revenueTotal = total(current, "revenue", "sum");
  const goal = dailyGoal * current.length;
  const progress = Math.min(1, revenueTotal / goal);
  const best = current.reduce((a, b) => (b.revenue > a.revenue ? b : a), current[0]);
  const avg = revenueTotal / current.length;
  const lastWeek = current.slice(-7).reduce((a, d) => a + d.revenue, 0) / Math.min(7, current.length);

  const xLabels = [0, 0.25, 0.5, 0.75, 1].map((f) => current[Math.round(f * (current.length - 1))]?.label ?? "");

  return (
    <section className={cn("mx-auto w-full max-w-[1100px] space-y-4", className)}>
      <header className="flex flex-wrap items-end justify-between gap-3">
        <div>
          <h2 className="font-semibold text-xl tracking-tight">{title}</h2>
          <p className="text-muted-foreground text-sm">
            {current[0]?.label} – {current[current.length - 1]?.label} vs previous {span} days
          </p>
        </div>
        <div aria-label="Range" className="inline-flex rounded-full border bg-muted p-1" role="radiogroup">
          {ranges.map((r) => {
            const active = r.value === range;
            return (
              <button
                aria-checked={active}
                className={cn(
                  "relative h-7 cursor-pointer rounded-full px-3.5 font-medium text-xs outline-none transition-colors duration-150 after:absolute after:-inset-y-1 after:inset-x-0 after:content-[''] focus-visible:ring-2 focus-visible:ring-ring",
                  active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
                )}
                key={r.value}
                onClick={() => {
                  setRange(r.value);
                  setHover(null);
                }}
                role="radio"
                type="button"
              >
                {active && (
                  <motion.span
                    className="absolute inset-0 rounded-full bg-background shadow-xs/5 dark:bg-accent"
                    layoutId={`${uid}-range`}
                    transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 44 }}
                  />
                )}
                <span className="relative tabular-nums">{r.label}</span>
              </button>
            );
          })}
        </div>
      </header>

      <div className="grid gap-4 sm:grid-cols-3">
        {metrics.map((m) => {
          const value = total(current, m.key, m.mode);
          const before = total(previous, m.key, m.mode);
          const change = before ? (value - before) / before : 0;
          const up = change >= 0;
          const shown = hovered ? hovered[m.key] : value;
          return (
            <article className="rounded-2xl border bg-card p-4 shadow-xs/5" key={m.key}>
              <div className="flex items-center justify-between gap-2">
                <h3 className="flex items-center gap-2 text-muted-foreground text-sm">
                  <span className={cn("size-2 rounded-full bg-current", m.tone)} />
                  {m.label}
                </h3>
                <span
                  className={cn(
                    "inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 font-medium text-xs tabular-nums",
                    up ? "bg-success/10 text-success-foreground" : "bg-destructive/10 text-destructive-foreground",
                  )}
                >
                  {up ? <ArrowUpRightIcon className="size-3" /> : <ArrowDownRightIcon className="size-3" />}
                  {Math.abs(change * 100).toFixed(1)}%
                </span>
              </div>
              <div className="mt-2 font-semibold text-2xl tracking-tight">
                <Roll instant={hover !== null} text={fmt(m.key, shown)} />
              </div>
              <p className="mt-0.5 h-4 text-muted-foreground text-xs">
                {hovered
                  ? `${hovered.weekday}, ${hovered.label}`
                  : m.mode === "avg"
                    ? `Daily average, last ${span} days`
                    : `Total, last ${span} days`}
              </p>
              <div className="mt-3">
                <Sparkline hover={hover} tone={m.tone} values={current.map((d) => d[m.key])} />
              </div>
            </article>
          );
        })}
      </div>

      <div className="grid gap-4 lg:grid-cols-3">
        <article className="rounded-2xl border bg-card p-4 shadow-xs/5 lg:col-span-2">
          <div className="flex flex-wrap items-center justify-between gap-2">
            <h3 className="font-medium text-sm">Revenue, this period vs last</h3>
            <div className="flex items-center gap-4 text-muted-foreground text-xs">
              <span className="flex items-center gap-1.5">
                <span className={cn("h-0.5 w-3.5 rounded-full bg-current", revenueTone)} />
                This period
              </span>
              <span className="flex items-center gap-1.5">
                <span className="w-3.5 border-muted-foreground/60 border-t-2 border-dashed" />
                Last period
              </span>
            </div>
          </div>

          <div className="relative mt-4 ps-10">
            <div className="pointer-events-none absolute inset-y-0 left-0 w-10">
              {gridValues.map((v) => (
                <span
                  className="absolute left-0 -translate-y-1/2 text-[0.6875rem] text-muted-foreground tabular-nums"
                  key={v}
                  style={{ top: `${(yOf(v) / H) * 100}%` }}
                >
                  {v >= 1000 ? `${(v / 1000).toFixed(1).replace(/\.0$/, "")}k` : Math.round(v)}
                </span>
              ))}
            </div>
            <div
              aria-label="Revenue chart. Use arrow keys to inspect days."
              className="relative h-56 cursor-crosshair touch-pan-y rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring"
              onBlur={() => setHover(null)}
              onKeyDown={onPlotKey}
              onPointerDown={pick}
              onPointerLeave={() => setHover(null)}
              onPointerMove={pick}
              ref={plotRef}
              role="img"
              tabIndex={0}
            >
              <svg aria-hidden className={cn("absolute inset-0 size-full overflow-visible", revenueTone)} preserveAspectRatio="none" viewBox={`0 0 ${W} ${H}`}>
                <defs>
                  <clipPath id={`${uid}-reveal`}>
                    <motion.rect
                      animate={{ width: W + 4 }}
                      height={H + 8}
                      initial={{ width: reduce ? W + 4 : 0 }}
                      transition={{ duration: reduce ? 0 : 0.7, ease: [0.23, 1, 0.32, 1] }}
                      x={-2}
                      y={-4}
                    />
                  </clipPath>
                  <linearGradient id={`${uid}-area`} x1="0" x2="0" y1="0" y2="1">
                    <stop offset="0%" stopColor="currentColor" stopOpacity={0.16} />
                    <stop offset="100%" stopColor="currentColor" stopOpacity={0} />
                  </linearGradient>
                </defs>
                {gridValues.map((v) => {
                  const y = yOf(v);
                  return (
                    <line
                      className="stroke-border"
                      key={v}
                      strokeDasharray="2 4"
                      vectorEffect="non-scaling-stroke"
                      x1={0}
                      x2={W}
                      y1={y}
                      y2={y}
                    />
                  );
                })}
                <motion.path
                  animate={{ opacity: 1 }}
                  className="stroke-muted-foreground/50"
                  d={prevLine}
                  fill="none"
                  initial={{ opacity: 0 }}
                  key={`prev-${span}`}
                  strokeDasharray="4 5"
                  strokeWidth="1.5"
                  transition={{ duration: reduce ? 0 : 0.2, ease: "easeOut" }}
                  vectorEffect="non-scaling-stroke"
                />
                <g clipPath={`url(#${uid}-reveal)`}>
                  <motion.g
                    animate={{ opacity: 1 }}
                    initial={{ opacity: 0 }}
                    key={`cur-${span}`}
                    transition={{ duration: reduce ? 0 : 0.2, ease: "easeOut" }}
                  >
                    <path d={`${curLine} L${W},${H} L0,${H} Z`} fill={`url(#${uid}-area)`} />
                    <path
                      d={curLine}
                      fill="none"
                      stroke="currentColor"
                      strokeLinecap="round"
                      strokeWidth="2.25"
                      vectorEffect="non-scaling-stroke"
                    />
                  </motion.g>
                </g>
              </svg>

              {hover !== null && hovered && (
                <>
                  <span
                    className="pointer-events-none absolute inset-y-0 w-px bg-foreground/16"
                    style={{ left: `${(curPts[hover][0] / W) * 100}%` }}
                  />
                  {hoveredPrev && (
                    <span
                      className="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card bg-muted-foreground"
                      style={{ left: `${(prevPts[hover][0] / W) * 100}%`, top: `${(prevPts[hover][1] / H) * 100}%` }}
                    />
                  )}
                  <span
                    className={cn("pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2", revenueTone)}
                    style={{ left: `${(curPts[hover][0] / W) * 100}%`, top: `${(curPts[hover][1] / H) * 100}%` }}
                  >
                    <motion.span
                      animate={{ scale: 1, opacity: 1 }}
                      className="block size-full rounded-full border-2 border-card bg-current"
                      initial={{ scale: reduce ? 1 : 0.6, opacity: 0 }}
                      transition={{ duration: 0.15, ease: "easeOut" }}
                    />
                  </span>
                  <div
                    className={cn(
                      "pointer-events-none absolute top-2 z-10 w-44 rounded-xl border bg-popover p-3 text-xs shadow-xs/5",
                      hover > current.length / 2 ? "-translate-x-[calc(100%+12px)]" : "translate-x-3",
                    )}
                    style={{ left: `${(curPts[hover][0] / W) * 100}%` }}
                  >
                    <p className="font-medium">
                      {hovered.weekday}, {hovered.label}
                    </p>
                    <dl className="mt-2 space-y-1">
                      <div className="flex items-center justify-between gap-2">
                        <dt className="flex items-center gap-1.5 text-muted-foreground">
                          <span className={cn("size-1.5 rounded-full bg-current", revenueTone)} />
                          This period
                        </dt>
                        <dd className="font-medium tabular-nums">{money(hovered.revenue)}</dd>
                      </div>
                      {hoveredPrev && (
                        <div className="flex items-center justify-between gap-2">
                          <dt className="flex items-center gap-1.5 text-muted-foreground">
                            <span className="size-1.5 rounded-full bg-muted-foreground" />
                            {hoveredPrev.label}
                          </dt>
                          <dd className="tabular-nums">{money(hoveredPrev.revenue)}</dd>
                        </div>
                      )}
                      <div className="flex items-center justify-between gap-2 border-t pt-1">
                        <dt className="text-muted-foreground">Orders</dt>
                        <dd className="tabular-nums">{plain(hovered.orders)}</dd>
                      </div>
                    </dl>
                  </div>
                </>
              )}
            </div>
            <div className="mt-2 flex justify-between text-[0.6875rem] text-muted-foreground tabular-nums">
              {xLabels.map((label, i) => (
                <span key={`${label}-${i}`}>{label}</span>
              ))}
            </div>
          </div>
        </article>

        <article className="flex flex-col rounded-2xl border bg-card p-4 shadow-xs/5">
          <div className="flex items-center gap-2">
            <span className="flex size-7 items-center justify-center rounded-lg bg-blue-500/12 text-blue-600 dark:text-blue-400">
              <TargetIcon className="size-4" />
            </span>
            <h3 className="font-medium text-sm">Focus: revenue goal</h3>
          </div>
          <div className="mt-4 flex items-center gap-5">
            <div className="relative size-28 shrink-0">
              <svg aria-hidden className={cn("size-full -rotate-90", revenueTone)} viewBox="0 0 100 100">
                <circle className="stroke-muted" cx="50" cy="50" fill="none" r="42" strokeWidth="10" />
                <motion.circle
                  animate={{ pathLength: progress }}
                  cx="50"
                  cy="50"
                  fill="none"
                  initial={false}
                  r="42"
                  stroke="currentColor"
                  strokeLinecap="round"
                  strokeWidth="10"
                  transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 120, damping: 22 }}
                />
              </svg>
              <div className="absolute inset-0 flex flex-col items-center justify-center">
                <span className="font-semibold text-xl tracking-tight">
                  <Roll text={`${Math.round(progress * 100)}%`} />
                </span>
                <span className="text-[0.6875rem] text-muted-foreground">of goal</span>
              </div>
            </div>
            <div className="min-w-0 text-sm">
              <p className="text-muted-foreground text-xs">Earned</p>
              <p className="font-semibold">
                <Roll text={money(revenueTotal)} />
              </p>
              <p className="mt-2 text-muted-foreground text-xs">Goal</p>
              <p className="font-medium text-muted-foreground">
                <Roll text={money(goal)} />
              </p>
            </div>
          </div>
          <dl className="mt-auto space-y-2 border-t pt-4 text-sm">
            <div className="flex items-center justify-between">
              <dt className="text-muted-foreground">Best day</dt>
              <dd className="font-medium tabular-nums">
                {best?.label} · <Roll text={money(best?.revenue ?? 0)} />
              </dd>
            </div>
            <div className="flex items-center justify-between">
              <dt className="text-muted-foreground">Average per day</dt>
              <dd className="font-medium">
                <Roll text={money(avg)} />
              </dd>
            </div>
            <div className="flex items-center justify-between">
              <dt className="text-muted-foreground">Last 7 days pace</dt>
              <dd
                className={cn(
                  "font-medium",
                  lastWeek >= dailyGoal ? "text-success-foreground" : "text-warning-foreground",
                )}
              >
                <Roll text={money(lastWeek)} />
                <span className="text-muted-foreground text-xs"> /day</span>
              </dd>
            </div>
          </dl>
        </article>
      </div>
    </section>
  );
}
```
