Hero with Live Updating Dashboard Mock

A dark hero with a centred headline above a dashboard panel that tilts up into view. The revenue figure counts up, eighteen bars grow one after another, and from then on a new sale arrives every 1.3 seconds: the totals glide to their new values, the chart moves along one step and a row drops into the feed. It is all drawn with divs, so there is no chart library and no image to load. On phones the panel stacks into a single column.

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, animate, motion, useReducedMotion } from "framer-motion";

/** How often the dashboard receives a new sale, in milliseconds. */
const TICK_MS = 1300;

const shops = ["Harbour St", "Old Mill", "Station Rd", "Parkside", "Kingsway"];
const items = ["Flat white x2", "Sourdough loaf", "Oat latte", "Cinnamon bun x4", "Filter coffee", "Almond croissant", "Cold brew x3"];
const startBars = [22, 30, 26, 41, 38, 52, 47, 63, 58, 71, 66, 80, 62, 74, 69, 88, 79, 92];

type Sale = { id: number; shop: string; item: string; amount: number };

const makeSale = (id: number): Sale => ({
  id,
  shop: shops[(id * 3) % shops.length],
  item: items[(id * 5) % items.length],
  amount: 4 + ((id * 37) % 23) + ((id * 13) % 10) / 10,
});

/** A number that glides to its new value instead of jumping. It writes to the DOM directly, so the rest of the tree does not re-render per frame. */
function Ticker({ value, format, still }: { value: number; format: (n: number) => string; still: boolean }) {
  const ref = useRef<HTMLSpanElement>(null);
  const from = useRef(still ? value : 0);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (still) {
      el.textContent = format(value);
      return;
    }
    const controls = animate(from.current, value, {
      duration: from.current === 0 ? 1.2 : 0.7,
      ease: [0.2, 0.8, 0.2, 1],
      onUpdate: (v) => {
        from.current = v;
        el.textContent = format(v);
      },
    });
    return () => controls.stop();
  }, [value, still, format]);
  return <span ref={ref} className="tabular-nums">{format(still ? value : 0)}</span>;
}

const money = (n: number) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const whole = (n: number) => Math.round(n).toLocaleString("en-US");

export default function HeroWithLiveDashboardMock() {
  const reduce = !!useReducedMotion();
  const ref = useRef<HTMLElement>(null);
  const [onScreen, setOnScreen] = useState(true);
  const [tick, setTick] = useState(0);
  const [bars, setBars] = useState(startBars);
  const [revenue, setRevenue] = useState(48216.4);
  const [orders, setOrders] = useState(1284);
  const [feed, setFeed] = useState<Sale[]>([makeSale(3), makeSale(2), makeSale(1)]);

  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting), { threshold: 0.05 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  useEffect(() => {
    if (reduce || !onScreen) return;
    const id = setInterval(() => {
      if (!document.hidden) setTick((t) => t + 1);
    }, TICK_MS);
    return () => clearInterval(id);
  }, [reduce, onScreen]);

  // One new sale per tick: the totals rise, the chart moves along one step and the feed gets a row.
  useEffect(() => {
    if (tick === 0) return;
    const sale = makeSale(tick + 3);
    setRevenue((r) => r + sale.amount * 3);
    setOrders((o) => o + 1 + (tick % 3));
    setBars((b) => [...b.slice(1), 55 + ((tick * 29) % 45)]);
    setFeed((f) => [sale, ...f].slice(0, 3));
  }, [tick]);

  const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950";
  const average = revenue / orders;

  return (
    <section ref={ref} data-paused={!onScreen} className="hld-root relative overflow-hidden bg-neutral-950 px-6 pb-16 pt-16 text-white sm:pt-20">
      <style>{`
        @keyframes hero-with-live-dashboard-mock-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
        @keyframes hero-with-live-dashboard-mock-scan { from { transform: translateX(-100%); } to { transform: translateX(400%); } }
        .hld-blink { animation: hero-with-live-dashboard-mock-blink 1.3s ease-in-out infinite; }
        .hld-scan { animation: hero-with-live-dashboard-mock-scan 3.2s ease-in-out infinite; }
        .hld-root[data-paused="true"] * { animation-play-state: paused !important; }
        @media (prefers-reduced-motion: reduce) { .hld-blink, .hld-scan { animation: none; } .hld-scan { display: none; } }
      `}</style>
      <div aria-hidden="true" className="pointer-events-none absolute left-1/2 top-64 h-96 w-full max-w-4xl -translate-x-1/2 rounded-full bg-emerald-500/20 blur-3xl" />

      <div className="relative mx-auto max-w-5xl">
        <motion.div className="mx-auto max-w-3xl text-center" initial={reduce ? false : { opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease: [0.2, 0.8, 0.2, 1] }}>
          <h1 className="text-4xl font-semibold tracking-tight sm:text-6xl">
            Watch today's sales <span className="block text-lime-300">as they happen.</span>
          </h1>
          <p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-neutral-300">Tillstream pulls every till in every shop into one live screen, so you spot a slow afternoon at lunch, not in next month's report.</p>
          <div className="mt-7 flex flex-col justify-center gap-3 sm:flex-row">
            <a href="#" className={`inline-flex items-center justify-center rounded-lg bg-lime-300 px-6 py-3 text-sm font-semibold text-neutral-950 transition hover:bg-lime-200 ${ring}`}>Connect your tills</a>
            <a href="#" className={`inline-flex items-center justify-center rounded-lg border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white/10 ${ring}`}>Open the live demo</a>
          </div>
        </motion.div>

        <div className="mt-12 [perspective:1600px]">
          <motion.div
            aria-hidden="true"
            className="origin-top overflow-hidden rounded-2xl border border-white/10 bg-neutral-900 shadow-2xl shadow-emerald-950"
            initial={reduce ? false : { opacity: 0, y: 60, rotateX: 28, scale: 0.94 }}
            animate={{ opacity: 1, y: 0, rotateX: 0, scale: 1 }}
            transition={{ type: "spring", stiffness: 70, damping: 16, delay: 0.15 }}
          >
            <div className="relative flex items-center justify-between gap-3 border-b border-white/10 px-5 py-3 text-sm">
              <span className="font-medium">All shops, today</span>
              <span className="inline-flex items-center gap-2 text-xs font-medium text-lime-300">
                <span className="hld-blink h-2 w-2 rounded-full bg-lime-300" />
                Live
              </span>
              <span className="hld-scan absolute bottom-0 left-0 h-px w-1/4 bg-gradient-to-r from-transparent via-lime-300 to-transparent" />
            </div>

            <div className="grid gap-px bg-white/10 md:grid-cols-3">
              <div className="bg-neutral-900 p-5 md:col-span-2 sm:p-6">
                <div className="flex flex-wrap items-end justify-between gap-3">
                  <div>
                    <p className="text-sm text-neutral-400">Revenue</p>
                    <p className="mt-1 text-4xl font-semibold tracking-tight sm:text-5xl">
                      <Ticker value={revenue} format={money} still={reduce} />
                    </p>
                  </div>
                  <p className="rounded-full bg-lime-300/10 px-2.5 py-1 text-xs font-medium text-lime-300">Up 12.4% on last Friday</p>
                </div>
                <div className="mt-6 flex h-36 items-end gap-1.5 sm:h-44 sm:gap-2">
                  {bars.map((h, i) => {
                    const last = i === bars.length - 1;
                    return (
                      <motion.span
                        key={i}
                        className={`h-full flex-1 origin-bottom rounded-t ${last ? "bg-lime-300 shadow-[0_0_24px_rgba(190,242,100,0.6)]" : "bg-gradient-to-t from-emerald-800 to-emerald-500"}`}
                        style={{ opacity: last ? 1 : 0.35 + (i / bars.length) * 0.65 }}
                        initial={reduce ? false : { scaleY: 0 }}
                        animate={{ scaleY: h / 100 }}
                        transition={tick === 0 ? { type: "spring", stiffness: 110, damping: 14, delay: 0.45 + i * 0.035 } : { type: "spring", stiffness: 140, damping: 16 }}
                      />
                    );
                  })}
                </div>
                <div className="mt-2 flex justify-between text-xs text-neutral-500">
                  <span>7 am</span>
                  <span>10 am</span>
                  <span>1 pm</span>
                  <span>Now</span>
                </div>
              </div>

              <div className="flex flex-col gap-px bg-white/10">
                <div className="grid grid-cols-2 gap-px bg-white/10">
                  <div className="bg-neutral-900 p-5">
                    <p className="text-sm text-neutral-400">Orders</p>
                    <p className="mt-1 text-2xl font-semibold tracking-tight"><Ticker value={orders} format={whole} still={reduce} /></p>
                  </div>
                  <div className="bg-neutral-900 p-5">
                    <p className="text-sm text-neutral-400">Avg. basket</p>
                    <p className="mt-1 text-2xl font-semibold tracking-tight"><Ticker value={average} format={money} still={reduce} /></p>
                  </div>
                </div>
                <div className="flex-1 bg-neutral-900 p-5">
                  <p className="text-sm text-neutral-400">Latest sales</p>
                  <ul className="relative mt-3 h-[184px] space-y-2 overflow-hidden">
                    <AnimatePresence initial={false} mode="popLayout">
                      {feed.map((s) => (
                        <motion.li
                          key={s.id}
                          layout
                          initial={{ opacity: 0, y: -24, scale: 0.96 }}
                          animate={{ opacity: 1, y: 0, scale: 1 }}
                          exit={{ opacity: 0, y: 16 }}
                          transition={{ type: "spring", stiffness: 260, damping: 24 }}
                          className="flex items-center justify-between gap-3 rounded-lg bg-white/5 px-3 py-2.5 text-sm"
                        >
                          <span className="min-w-0">
                            <span className="block truncate font-medium">{s.item}</span>
                            <span className="block truncate text-xs text-neutral-400">{s.shop}</span>
                          </span>
                          <span className="font-medium tabular-nums text-lime-300">+{money(s.amount)}</span>
                        </motion.li>
                      ))}
                    </AnimatePresence>
                  </ul>
                </div>
              </div>
            </div>
          </motion.div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/hero-with-live-dashboard-mock.json

When to use it

How to customize it

Accessibility

Questions

How do the numbers count up smoothly?

A small `Ticker` component calls the `animate` function from the motion library to go from the old value to the new one, and writes each in-between value straight into the element's text. Writing to the DOM directly means React does not re-render the dashboard sixty times a second.

How is the bar chart animated without a chart library?

Every bar is a full-height span that is scaled on the vertical axis from its bottom edge. Changing the scale is a transform, so it is cheap, and a spring makes each bar overshoot slightly and settle.

Is the data real?

No. The values come from simple arithmetic on a counter, so the same sequence plays every time and nothing is fetched. To show real figures, replace the effect that runs on each tick with your own data source.

Does it keep ticking when it is off screen?

No. The interval is cleared when the section leaves the viewport and skipped while the tab is hidden, then restarted when it comes back.

How does the new row push the others down?

The feed list uses layout animation: when a row is added at the top, the rows below it animate to their new positions and the oldest one fades out.

Related: Dark Hero with Gradient Glow and Product Window, Stat Tiles with Sparkline Trends, Hero with Word Reveal Headline and Floating Cards.

More hero sections or all free UI components. Start for free to have a full site built around it.

More in Hero Sections

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap