Web InnoventixFreeCode

Dashboard Users

Original · free

active users widget

byWeb InnoventixReact + Tailwind
dashusersdashboards
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/dash-users.json
dash-users.tsx
"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";

function subscribeReduced(onChange: () => void): () => void {
  const mq = window.matchMedia(REDUCE_QUERY);
  mq.addEventListener("change", onChange);
  return () => mq.removeEventListener("change", onChange);
}

function getReducedSnapshot(): boolean {
  return window.matchMedia(REDUCE_QUERY).matches;
}

function getReducedServerSnapshot(): boolean {
  return false;
}

type WindowId = "5m" | "30m" | "24h";
type Unit = "s" | "m" | "h";
type Point = { id: number; v: number };
type Series = Record<WindowId, Point[]>;
type BarStyle = CSSProperties & Record<`--${string}`, string | number>;

type WindowDef = {
  id: WindowId;
  name: string;
  short: string;
  buckets: number;
  step: number;
  unit: Unit;
  base: number;
  amp: number;
  caption: string;
  avgLabel: string;
};

const WINDOWS: readonly WindowDef[] = [
  {
    id: "5m",
    name: "Last 5 minutes",
    short: "5 min",
    buckets: 30,
    step: 10,
    unit: "s",
    base: 980,
    amp: 150,
    caption: "Concurrent users, sampled every 10 seconds",
    avgLabel: "5-min average",
  },
  {
    id: "30m",
    name: "Last 30 minutes",
    short: "30 min",
    buckets: 30,
    step: 1,
    unit: "m",
    base: 910,
    amp: 300,
    caption: "Concurrent users, sampled every minute",
    avgLabel: "30-min average",
  },
  {
    id: "24h",
    name: "Last 24 hours",
    short: "24 h",
    buckets: 24,
    step: 1,
    unit: "h",
    base: 720,
    amp: 560,
    caption: "Concurrent users, hourly high-water mark",
    avgLabel: "24-hour average",
  },
];

const CHANNELS: readonly { label: string; weight: number; bar: string; dot: string }[] = [
  { label: "Organic search", weight: 0.4, bar: "bg-indigo-500", dot: "bg-indigo-500" },
  { label: "Direct", weight: 0.26, bar: "bg-violet-500", dot: "bg-violet-500" },
  { label: "Referral", weight: 0.16, bar: "bg-sky-500", dot: "bg-sky-500" },
  { label: "Social", weight: 0.11, bar: "bg-emerald-500", dot: "bg-emerald-500" },
  { label: "Email", weight: 0.07, bar: "bg-amber-500", dot: "bg-amber-500" },
];

const PAGES: readonly { path: string; tag: string; weight: number }[] = [
  { path: "/pricing", tag: "Marketing", weight: 0.21 },
  { path: "/docs/quickstart", tag: "Docs", weight: 0.17 },
  { path: "/app/projects", tag: "Product", weight: 0.15 },
  { path: "/blog/edge-cache-invalidation", tag: "Blog", weight: 0.11 },
  { path: "/changelog", tag: "Marketing", weight: 0.07 },
];

function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function hash01(seed: number, k: number): number {
  const x = Math.sin(seed * 12.9898 + k * 78.233) * 43758.5453;
  return x - Math.floor(x);
}

function makeSeries(seed: number, w: WindowDef): Point[] {
  const rnd = mulberry32(seed);
  const out: Point[] = [];
  let drift = 0;
  for (let i = 0; i < w.buckets; i++) {
    drift += (rnd() - 0.47) * w.amp * 0.16;
    const wave = Math.sin((i / w.buckets) * Math.PI * 2.1) * w.amp * 0.4;
    const v = Math.round(w.base + wave + drift + (rnd() - 0.5) * w.amp * 0.24);
    out.push({ id: i, v: Math.max(80, v) });
  }
  return out;
}

function advance(pts: Point[], w: WindowDef, rnd: () => number): Point[] {
  const last = pts[pts.length - 1] ?? { id: 0, v: w.base };
  const pull = (w.base - last.v) * 0.09;
  const noise = (rnd() - 0.5) * w.amp * 0.18;
  const v = Math.max(80, Math.round(last.v + pull + noise));
  return [...pts.slice(1), { id: last.id + 1, v }];
}

function jitterWeights(weights: readonly number[], seed: number, salt: number): number[] {
  return weights.map((wt, k) => wt * (0.84 + 0.32 * hash01(seed + salt, k)));
}

function agoLabel(fromEnd: number, w: WindowDef): string {
  if (fromEnd <= 0) return "now";
  const n = fromEnd * w.step;
  if (w.unit === "s") {
    if (n < 60) return `${n}s ago`;
    const m = Math.floor(n / 60);
    const s = n % 60;
    return s === 0 ? `${m}m ago` : `${m}m ${s}s ago`;
  }
  if (w.unit === "m") return n === 1 ? "1 min ago" : `${n} min ago`;
  return n === 1 ? "1 h ago" : `${n} h ago`;
}

function fmt(n: number): string {
  return n.toLocaleString("en-US");
}

function PauseIcon() {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5" fill="currentColor">
      <rect x="4" y="3" width="3" height="10" rx="1" />
      <rect x="9" y="3" width="3" height="10" rx="1" />
    </svg>
  );
}

function PlayIcon() {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5" fill="currentColor">
      <path d="M5 3.6c0-.8.9-1.3 1.5-.8l5.6 3.9c.6.4.6 1.3 0 1.7l-5.6 3.9c-.7.5-1.5 0-1.5-.8V3.6Z" />
    </svg>
  );
}

export default function DashUsers() {
  const reduce = useReducedMotion();
  const groupLabelId = useId();
  const chartLabelId = useId();

  const [windowId, setWindowId] = useState<WindowId>("5m");
  const [liveOverride, setLiveOverride] = useState<boolean | null>(null);
  const [hoverIdx, setHoverIdx] = useState<number | null>(null);
  const [focusIdx, setFocusIdx] = useState<number | null>(null);
  const [rovingIdx, setRovingIdx] = useState<number | null>(null);
  const [status, setStatus] = useState("Live user count updating every 2 seconds.");
  const [series, setSeries] = useState<Series>(() => ({
    "5m": makeSeries(20260717, WINDOWS[0]),
    "30m": makeSeries(41220913, WINDOWS[1]),
    "24h": makeSeries(70431188, WINDOWS[2]),
  }));

  const rndRef = useRef<(() => number) | null>(null);
  const barsRef = useRef<Array<HTMLButtonElement | null>>([]);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const rnd = useCallback(() => {
    if (!rndRef.current) rndRef.current = mulberry32(90210457);
    return rndRef.current();
  }, []);

  // Motion-sensitive visitors start paused; an explicit choice always wins.
  // This drives rendered markup, so it can't read useReducedMotion() directly: that
  // resolves to true on the client's first paint but false during SSR, which
  // hydration-mismatches the pill. useSyncExternalStore hands React a server
  // snapshot for the first render, then settles on the real value.
  const reducedDefault = useSyncExternalStore(subscribeReduced, getReducedSnapshot, getReducedServerSnapshot);
  const live = liveOverride ?? !reducedDefault;

  useEffect(() => {
    if (!live) return;
    const t = window.setInterval(() => {
      setSeries((prev) => ({
        "5m": advance(prev["5m"], WINDOWS[0], rnd),
        "30m": advance(prev["30m"], WINDOWS[1], rnd),
        "24h": advance(prev["24h"], WINDOWS[2], rnd),
      }));
    }, 2000);
    return () => window.clearInterval(t);
  }, [live, rnd]);

  const w = WINDOWS.find((x) => x.id === windowId) ?? WINDOWS[0];
  const pts = series[windowId];
  const n = pts.length;

  const { nowV, avg, peak, floor, ceil } = useMemo(() => {
    const total = pts.reduce((a, p) => a + p.v, 0);
    const mean = total / Math.max(1, pts.length);
    const hi = pts.reduce((a, p) => Math.max(a, p.v), 0);
    const lo = pts.reduce((a, p) => Math.min(a, p.v), Number.POSITIVE_INFINITY);
    // Concurrency never drops near zero, so a zero baseline flattens every bar into
    // one wall. Frame the band around the data instead and keep some headroom.
    const spread = Math.max(1, hi - lo);
    return {
      nowV: pts[pts.length - 1]?.v ?? 0,
      avg: mean,
      peak: hi,
      floor: Math.max(0, lo - spread * 0.75),
      ceil: hi + spread * 0.18,
    };
  }, [pts]);

  const pctOf = useCallback(
    (v: number) => {
      const span = Math.max(1, ceil - floor);
      return Math.min(100, Math.max(2, ((v - floor) / span) * 100));
    },
    [floor, ceil],
  );

  const deltaPct = avg > 0 ? ((nowV - avg) / avg) * 100 : 0;
  const up = deltaPct >= 0;

  const probing = focusIdx !== null || hoverIdx !== null;
  const activeIdx = Math.min(n - 1, focusIdx ?? hoverIdx ?? n - 1);
  const activePoint = pts[activeIdx] ?? pts[n - 1];
  const activeCount = activePoint?.v ?? 0;
  const activeId = activePoint?.id ?? 0;
  const activeAgo = agoLabel(n - 1 - activeIdx, w);

  const channels = useMemo(() => {
    const raw = jitterWeights(
      CHANNELS.map((c) => c.weight),
      activeId,
      11,
    );
    const sum = raw.reduce((a, b) => a + b, 0) || 1;
    return CHANNELS.map((c, k) => {
      const share = (raw[k] ?? 0) / sum;
      return { ...c, share, count: Math.round(share * activeCount) };
    });
  }, [activeId, activeCount]);

  const pages = useMemo(() => {
    const raw = jitterWeights(
      PAGES.map((p) => p.weight),
      activeId,
      29,
    );
    const used = raw.reduce((a, b) => a + b, 0);
    const top = Math.max(...raw, 0.0001);
    const rows = PAGES.map((p, k) => {
      const share = raw[k] ?? 0;
      return { ...p, share, rel: (share / top) * 100, count: Math.round(share * activeCount) };
    });
    const otherShare = Math.max(0.05, 1 - used);
    return { rows, otherCount: Math.round(otherShare * activeCount) };
  }, [activeId, activeCount]);

  const roving = Math.min(rovingIdx ?? n - 1, n - 1);
  const guidePct = ((activeIdx + 0.5) / n) * 100;
  const chipPct = Math.min(90, Math.max(10, guidePct));
  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 210, damping: 26, mass: 0.6 };

  function selectWindow(id: WindowId) {
    setWindowId(id);
    setHoverIdx(null);
    setFocusIdx(null);
    setRovingIdx(null);
    const next = WINDOWS.find((x) => x.id === id);
    setStatus(next ? `Showing ${next.name.toLowerCase()}.` : "Time window changed.");
  }

  function onTabsKey(e: KeyboardEvent<HTMLDivElement>) {
    const i = WINDOWS.findIndex((x) => x.id === windowId);
    let next = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % WINDOWS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + WINDOWS.length) % WINDOWS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = WINDOWS.length - 1;
    else return;
    e.preventDefault();
    const target = WINDOWS[next];
    if (!target) return;
    selectWindow(target.id);
    tabRefs.current[next]?.focus();
  }

  function onBarsKey(e: KeyboardEvent<HTMLDivElement>) {
    let next = roving;
    if (e.key === "ArrowRight") next = Math.min(n - 1, roving + 1);
    else if (e.key === "ArrowLeft") next = Math.max(0, roving - 1);
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = n - 1;
    else if (e.key === "Escape") {
      e.preventDefault();
      barsRef.current[roving]?.blur();
      setFocusIdx(null);
      setStatus("Left the chart. Showing the live count.");
      return;
    } else return;
    e.preventDefault();
    setRovingIdx(next);
    barsRef.current[next]?.focus();
  }

  function toggleLive() {
    const nextLive = !live;
    setLiveOverride(nextLive);
    setStatus(nextLive ? "Live updates resumed." : `Live updates paused at ${fmt(nowV)} active users.`);
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes dau-ping {
          0% { transform: scale(1); opacity: .5; }
          70% { transform: scale(2.6); opacity: 0; }
          100% { transform: scale(2.6); opacity: 0; }
        }
        @keyframes dau-rise {
          from { transform: scaleY(.12); opacity: 0; }
          to { transform: scaleY(1); opacity: 1; }
        }
        @keyframes dau-breathe {
          0%, 100% { opacity: .3; }
          50% { opacity: .65; }
        }
        .dau-bar {
          transform-origin: bottom;
          animation: dau-rise .55s cubic-bezier(.2,.85,.25,1) both;
          animation-delay: var(--dau-d, 0ms);
        }
        .dau-ping { animation: dau-ping 1.9s cubic-bezier(0,0,.2,1) infinite; }
        .dau-breathe { animation: dau-breathe 5s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .dau-bar, .dau-ping, .dau-breathe { animation: none !important; }
          .dau-bar { opacity: 1; transform: none; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="dau-breathe pointer-events-none absolute -top-28 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20"
      />

      <div className="relative mx-auto w-full max-w-4xl">
        <p className="sr-only" role="status" aria-live="polite">
          {status}
        </p>

        <div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 dark:border-slate-800 dark:bg-slate-900">
          {/* Header */}
          <div className="flex flex-wrap items-start justify-between gap-4">
            <div className="flex items-start gap-3">
              <span className="mt-0.5 grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
                <svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
                  <path d="M2.5 19.5a6.5 6.5 0 0 1 13 0" />
                  <path d="M16.5 4.6a3.5 3.5 0 0 1 0 6.8" />
                  <path d="M18 14.4a6.5 6.5 0 0 1 3.5 5.1" />
                </svg>
              </span>
              <div>
                <div className="flex items-center gap-2">
                  <h2 className="text-sm font-semibold tracking-tight text-slate-900 dark:text-slate-100">
                    Active users right now
                  </h2>
                  <span
                    className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ${
                      live
                        ? "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/25"
                        : "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/25"
                    }`}
                  >
                    <span className="relative flex h-1.5 w-1.5">
                      {live ? (
                        <span className="dau-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
                      ) : null}
                      <span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${live ? "bg-emerald-500" : "bg-amber-500"}`} />
                    </span>
                    {live ? "Live" : "Paused"}
                  </span>
                </div>
                <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                  {w.caption}
                </p>
              </div>
            </div>

            <div className="flex items-center gap-2">
              <div
                role="radiogroup"
                aria-labelledby={groupLabelId}
                onKeyDown={onTabsKey}
                className="flex rounded-lg bg-slate-100 p-0.5 dark:bg-slate-800"
              >
                <span id={groupLabelId} className="sr-only">
                  Time window
                </span>
                {WINDOWS.map((x, i) => {
                  const on = x.id === windowId;
                  return (
                    <button
                      key={x.id}
                      ref={(el) => {
                        tabRefs.current[i] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={on}
                      aria-label={x.name}
                      tabIndex={on ? 0 : -1}
                      onClick={() => selectWindow(x.id)}
                      className={`rounded-[7px] px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                        on
                          ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-slate-50"
                          : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200"
                      }`}
                    >
                      {x.short}
                    </button>
                  );
                })}
              </div>

              <button
                type="button"
                onClick={toggleLive}
                aria-label={live ? "Pause live updates" : "Resume live updates"}
                className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
              >
                {live ? <PauseIcon /> : <PlayIcon />}
                {live ? "Pause" : "Resume"}
              </button>
            </div>
          </div>

          {/* Headline metric */}
          <div className="mt-5 flex flex-wrap items-end gap-x-4 gap-y-2">
            <span className="text-5xl font-semibold tracking-tighter tabular-nums text-slate-900 sm:text-6xl dark:text-slate-50">
              {fmt(nowV)}
            </span>
            <span
              className={`mb-2 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ring-1 ${
                up
                  ? "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/25"
                  : "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/25"
              }`}
            >
              <svg viewBox="0 0 12 12" aria-hidden="true" className={`h-3 w-3 ${up ? "" : "rotate-180"}`} fill="currentColor">
                <path d="M6 2.2 10.2 7H7.4v3H4.6V7H1.8L6 2.2Z" />
              </svg>
              {up ? "+" : "−"}
              {Math.abs(deltaPct).toFixed(1)}%
            </span>
            <span className="mb-2 text-xs text-slate-500 dark:text-slate-400">
              vs. {w.avgLabel} of {fmt(Math.round(avg))} · peak {fmt(peak)}
            </span>
          </div>

          {/* Chart */}
          <div className="relative mt-4 pt-9">
            <span id={chartLabelId} className="sr-only">
              Active users, {w.name.toLowerCase()}. Use the arrow keys to inspect each sample.
            </span>

            <div className="flex items-stretch gap-2">
              {/* The bars row is the positioning context, so every overlay shares the bars' scale. */}
              <div
                role="group"
                aria-labelledby={chartLabelId}
                onKeyDown={onBarsKey}
                onMouseLeave={() => setHoverIdx(null)}
                className="relative flex h-40 flex-1 items-end gap-[3px] sm:h-48"
              >
                <motion.div
                  aria-hidden="true"
                  className="pointer-events-none absolute -top-9 z-10 -translate-x-1/2"
                  initial={false}
                  animate={{ left: `${chipPct}%` }}
                  transition={spring}
                >
                  <span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] shadow-sm dark:border-slate-700 dark:bg-slate-800">
                    <span className="font-semibold tabular-nums text-slate-900 dark:text-slate-100">
                      {fmt(activeCount)}
                    </span>
                    <span className="text-slate-400 dark:text-slate-500">·</span>
                    <span className="text-slate-500 dark:text-slate-400">{activeAgo}</span>
                  </span>
                </motion.div>

                <div
                  aria-hidden="true"
                  className="pointer-events-none absolute inset-x-0 border-t border-dashed border-slate-300 dark:border-slate-700"
                  style={{ bottom: `${pctOf(avg)}%` }}
                />

                {pts.map((p, i) => {
                  const h = pctOf(p.v);
                  const on = i === activeIdx;
                  const label = agoLabel(n - 1 - i, w);
                  const barStyle: BarStyle = { "--dau-d": `${i * 16}ms`, height: `${h}%` };
                  return (
                    <button
                      key={i}
                      ref={(el) => {
                        barsRef.current[i] = el;
                      }}
                      type="button"
                      tabIndex={i === roving ? 0 : -1}
                      aria-label={`${label}: ${fmt(p.v)} active users`}
                      onFocus={() => {
                        setFocusIdx(i);
                        setRovingIdx(i);
                      }}
                      onBlur={() => setFocusIdx(null)}
                      onMouseEnter={() => setHoverIdx(i)}
                      onClick={() => setStatus(`${label}: ${fmt(p.v)} active users.`)}
                      className="relative flex h-full flex-1 items-end rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                    >
                      <motion.span
                        aria-hidden="true"
                        className={`dau-bar w-full rounded-t-[3px] bg-gradient-to-t transition-colors ${
                          on
                            ? "from-indigo-600 to-violet-400 dark:from-indigo-400 dark:to-violet-300"
                            : "from-indigo-500/55 to-violet-400/45 dark:from-indigo-500/50 dark:to-violet-400/40"
                        }`}
                        style={barStyle}
                        initial={false}
                        animate={{ height: `${h}%` }}
                        transition={spring}
                      />
                    </button>
                  );
                })}

                {/* Rendered after the bars so it paints over them. */}
                <motion.div
                  aria-hidden="true"
                  className={`pointer-events-none absolute inset-y-0 w-px ${
                    probing ? "bg-indigo-600/50 dark:bg-indigo-300/50" : "bg-slate-900/15 dark:bg-white/15"
                  }`}
                  initial={false}
                  animate={{ left: `${guidePct}%` }}
                  transition={spring}
                />
              </div>

              {/* Axis gutter: keeps the reference label off the bars while sharing their scale. */}
              <div aria-hidden="true" className="relative w-10 shrink-0">
                <span
                  className="absolute right-0 -translate-y-1/2 whitespace-nowrap text-[10px] font-medium tabular-nums text-slate-400 dark:text-slate-500"
                  style={{ bottom: `${pctOf(avg)}%` }}
                >
                  avg {fmt(Math.round(avg))}
                </span>
              </div>
            </div>

            <div className="mt-2 flex items-center justify-between pr-12 text-[10px] font-medium text-slate-400 dark:text-slate-500">
              <span>{agoLabel(n - 1, w)}</span>
              <span>now</span>
            </div>
          </div>

          {/* Breakdown */}
          <div className="mt-6 grid gap-4 border-t border-slate-200 pt-6 md:grid-cols-2 dark:border-slate-800">
            <section aria-label="Traffic sources">
              <div className="flex items-baseline justify-between gap-2">
                <h3 className="text-xs font-semibold text-slate-900 dark:text-slate-100">Where they came from</h3>
                <span className="text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
                  {probing ? activeAgo : "live"}
                </span>
              </div>

              <div className="mt-3 flex h-2 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
                {channels.map((c) => (
                  <motion.span
                    key={c.label}
                    aria-hidden="true"
                    className={c.bar}
                    initial={false}
                    animate={{ width: `${c.share * 100}%` }}
                    transition={spring}
                  />
                ))}
              </div>

              <ul className="mt-3 space-y-2">
                {channels.map((c) => (
                  <li key={c.label} className="flex items-center gap-2.5 text-xs">
                    <span aria-hidden="true" className={`h-2 w-2 shrink-0 rounded-full ${c.dot}`} />
                    <span className="text-slate-600 dark:text-slate-300">{c.label}</span>
                    <span className="ml-auto font-medium tabular-nums text-slate-900 dark:text-slate-100">
                      {fmt(c.count)}
                    </span>
                    <span className="w-10 text-right tabular-nums text-slate-400 dark:text-slate-500">
                      {(c.share * 100).toFixed(0)}%
                    </span>
                  </li>
                ))}
              </ul>
            </section>

            <section aria-label="Top pages">
              <div className="flex items-baseline justify-between gap-2">
                <h3 className="text-xs font-semibold text-slate-900 dark:text-slate-100">What they&rsquo;re looking at</h3>
                <span className="text-[11px] text-slate-400 dark:text-slate-500">users</span>
              </div>

              <ul className="mt-3 space-y-2.5">
                {pages.rows.map((p) => (
                  <li key={p.path}>
                    <div className="flex items-center gap-2 text-xs">
                      <span className="truncate font-mono text-[11px] text-slate-700 dark:text-slate-300">{p.path}</span>
                      <span className="shrink-0 rounded border border-slate-200 px-1 py-px text-[9px] font-medium uppercase tracking-wide text-slate-400 dark:border-slate-700 dark:text-slate-500">
                        {p.tag}
                      </span>
                      <span className="ml-auto shrink-0 font-medium tabular-nums text-slate-900 dark:text-slate-100">
                        {fmt(p.count)}
                      </span>
                    </div>
                    <div className="mt-1 h-1 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
                      <motion.span
                        aria-hidden="true"
                        className="block h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-400"
                        initial={false}
                        animate={{ width: `${p.rel}%` }}
                        transition={spring}
                      />
                    </div>
                  </li>
                ))}
              </ul>

              <p className="mt-3 text-[11px] text-slate-400 dark:text-slate-500">
                + 96 other pages · <span className="tabular-nums">{fmt(pages.otherCount)}</span> users
              </p>
            </section>
          </div>

          <p className="mt-6 border-t border-slate-200 pt-4 text-[11px] leading-relaxed text-slate-400 dark:border-slate-800 dark:text-slate-500">
            Counted at the edge, refreshed every 2 seconds. A session drops out after 5 minutes without a request, so
            this number trails a hard refresh by a few seconds.
          </p>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →