Website Sections
DashboardHero
A headline over a working dashboard. The screen tips up from a tilt, numbers count up, the chart draws itself, and it leans toward the pointer.
Download
- React
- Tailwind CSS v4
- motion
components/ui/dashboard-hero.tsx
"use client";
import { useEffect, useId, useRef, type CSSProperties, type ElementType, type PointerEvent, type ReactNode } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
export type DashboardHeroStat = {
label: string;
value: number;
/** Goes before the number, such as $. */
prefix?: string;
/** Goes after the number, such as %. */
suffix?: string;
/** Small note under the number, such as +12%. */
note?: string;
};
type DashboardHeroProps = {
title: string;
/** Tiles along the top of the screen. */
stats: DashboardHeroStat[];
/** The line chart, oldest first. Two values or more. */
series: number[];
/** Names the chart, for screen readers too. */
seriesLabel?: string;
/** The bar chart. Leave out for a wider line chart. */
bars?: { label: string; value: number }[];
/** Name in the window bar. */
appName?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Buttons or links under the text. */
children?: ReactNode;
/** Chart colour. */
color?: string;
/** Colour of the dashboard panel. */
tile?: string;
/** h1 by default. Use h2 if the page already has an h1. */
headingAs?: ElementType;
/** Set the background and text colour here. */
className?: string;
};
const EASE = [0.16, 1, 0.3, 1] as const;
const SPRING = { stiffness: 110, damping: 18 };
const places = (value: number) => (String(value).split(".")[1] ?? "").length;
const format = (value: number, digits: number) => value.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
function Count({ stat }: { stat: DashboardHeroStat }) {
const number = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = number.current;
if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const digits = places(stat.value);
const run = animate(0, stat.value, {
duration: 1.5,
delay: 0.35,
ease: EASE,
onUpdate: (now) => {
el.textContent = format(now, digits);
},
});
return () => run.stop();
}, [stat.value]);
return (
<>
{stat.prefix}
<span ref={number}>{format(stat.value, places(stat.value))}</span>
{stat.suffix}
</>
);
}
/* DashboardHero: a headline over a working dashboard. The screen tips up from a tilt, numbers count up, the chart draws itself, and it leans gently toward the pointer. */
export function DashboardHero({
title,
stats,
series,
seriesLabel = "Trend",
bars,
appName = "Overview",
eyebrow,
subtitle,
children,
color = "#a78bfa",
tile = "#15122a",
headingAs: Heading = "h1",
className = "bg-[#0b0a14] text-white",
}: DashboardHeroProps) {
const reduce = useReducedMotion();
const id = useId();
const panel = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotateY = useSpring(useTransform(x, [-0.5, 0.5], [-4, 4]), SPRING);
const rotateX = useSpring(useTransform(y, [-0.5, 0.5], [3, -3]), SPRING);
const move = (e: PointerEvent) => {
const box = panel.current?.getBoundingClientRect();
if (reduce || e.pointerType !== "mouse" || !box) return;
x.set(Math.max(-0.5, Math.min(0.5, (e.clientX - box.left) / box.width - 0.5)));
y.set(Math.max(-0.5, Math.min(0.5, (e.clientY - box.top) / box.height - 0.5)));
};
const leave = () => {
x.set(0);
y.set(0);
};
const rise = (delay: number) =>
reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.4 } }
: { initial: { opacity: 0, y: 22 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.7, delay, ease: EASE } };
const low = Math.min(...series);
const span = Math.max(...series) - low || 1;
const points = series.map((value, i) => [(i / (series.length - 1)) * 100, 37 - ((value - low) / span) * 33] as const);
const line = points.map(([px, py], i) => `${i ? "L" : "M"}${px.toFixed(2)} ${py.toFixed(2)}`).join(" ");
const end = points[points.length - 1];
const tallest = Math.max(...(bars ?? []).map((bar) => bar.value), 1);
return (
<section aria-labelledby={id} onPointerMove={move} onPointerLeave={leave} className={`@container overflow-hidden ${className}`}>
<div className="mx-auto max-w-6xl px-5 pt-8 @3xl:pt-20">
<div className="mx-auto max-w-3xl text-center">
{eyebrow && (
<motion.p {...rise(0)} className="mb-3 text-xs font-medium tracking-[0.2em] uppercase opacity-70 @2xl:text-sm">
{eyebrow}
</motion.p>
)}
<motion.div {...rise(0.08)}>
<Heading id={id} className="text-[clamp(1.6rem,8cqw,2.5rem)] leading-[1.06] font-semibold tracking-tight text-balance @2xl:text-[clamp(2rem,5.5cqw,4rem)]">
{title}
</Heading>
</motion.div>
{subtitle && (
<motion.p {...rise(0.16)} className="mx-auto mt-3 max-w-xl text-sm text-pretty opacity-70 @2xl:mt-5 @2xl:text-lg">
{subtitle}
</motion.p>
)}
{children && (
<motion.div {...rise(0.24)} className="mt-5 flex flex-wrap justify-center gap-3 @2xl:mt-8">
{children}
</motion.div>
)}
</div>
<div ref={panel} className="mt-8 [perspective:1400px] @3xl:mt-14">
<motion.div style={{ rotateX, rotateY }}>
<motion.div
className="origin-bottom overflow-hidden rounded-t-2xl border border-b-0 border-current/15 shadow-[0_-20px_80px_-30px_var(--dh-color)]"
style={{ backgroundColor: tile, "--dh-color": color } as CSSProperties}
initial={reduce ? { opacity: 0 } : { opacity: 0, rotateX: 24, y: 50 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, rotateX: 0, y: 0 }}
transition={{ duration: reduce ? 0.4 : 1.2, delay: reduce ? 0 : 0.2, ease: EASE }}
>
<div className="flex items-center gap-1.5 border-b border-current/10 px-4 py-2.5">
{[0, 1, 2].map((dot) => (
<span key={dot} aria-hidden className="size-2 rounded-full bg-current opacity-20" />
))}
<span className="ml-2 text-xs opacity-60">{appName}</span>
</div>
<div className="grid gap-3 p-3 @3xl:p-4">
<dl className="grid grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-3">
{stats.map((stat) => (
<div key={stat.label} className="rounded-xl border border-current/10 p-3">
<dt className="text-xs opacity-60">{stat.label}</dt>
<dd className="mt-1 text-xl font-semibold tracking-tight tabular-nums @3xl:text-2xl">
<Count stat={stat} />
</dd>
{stat.note && (
<dd className="mt-0.5 text-xs" style={{ color }}>
{stat.note}
</dd>
)}
</div>
))}
</dl>
<div className={`grid gap-3 ${bars ? "@3xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]" : ""}`}>
<figure className="rounded-xl border border-current/10 p-3">
<figcaption className="mb-2 text-xs opacity-60">{seriesLabel}</figcaption>
<div className="relative h-32 @3xl:h-44">
<motion.div
className="size-full"
initial={{ clipPath: "inset(-5% 100% -5% 0%)" }}
animate={{ clipPath: "inset(-5% 0% -5% 0%)" }}
transition={{ duration: reduce ? 0 : 1.6, delay: reduce ? 0 : 0.5, ease: "easeInOut" }}
>
<svg role="img" aria-label={`${seriesLabel}: from ${series[0]} to ${series[series.length - 1]}`} viewBox="0 0 100 40" preserveAspectRatio="none" className="size-full overflow-visible">
<defs>
<linearGradient id={`${id}-fill`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={color} stopOpacity="0.35" />
<stop offset="1" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path d={`${line} L100 40 L0 40 Z`} fill={`url(#${id}-fill)`} />
<path d={line} fill="none" stroke={color} strokeWidth="2" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</svg>
</motion.div>
<motion.span
aria-hidden
className="absolute -mt-1 -ml-1 size-2 rounded-full"
style={{ left: `${end[0]}%`, top: `${(end[1] / 40) * 100}%`, backgroundColor: color }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: reduce ? 0 : 2 }}
>
<span className="absolute inset-0 animate-ping rounded-full motion-reduce:hidden" style={{ backgroundColor: color }} />
</motion.span>
</div>
</figure>
{bars && (
<ul className="flex items-stretch gap-2 rounded-xl border border-current/10 p-3">
{bars.map((bar, i) => (
<li key={bar.label} className="flex min-h-32 min-w-0 flex-1 flex-col gap-1 text-center text-[11px] @3xl:min-h-44">
<div className="flex min-h-0 flex-1 items-end">
<motion.span
className="block w-full origin-bottom rounded-t"
style={{ height: `${(bar.value / tallest) * 100}%`, backgroundColor: color }}
initial={{ scaleY: reduce ? 1 : 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: reduce ? 0 : 0.8, delay: reduce ? 0 : 0.7 + i * 0.07, ease: EASE }}
/>
</div>
<span className="truncate opacity-60">
<span className="sr-only">{bar.value} </span>
{bar.label}
</span>
</li>
))}
</ul>
)}
</div>
</div>
</motion.div>
</motion.div>
</div>
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/dashboard-hero.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/dashboard-hero-demo.tsx
import { DashboardHero } from "@/components/ui/dashboard-hero"; const STATS = [ { label: "Revenue", value: 48250, prefix: "$", note: "+12% this month" }, { label: "Active users", value: 3912, note: "+284 this week" }, { label: "Uptime", value: 99.98, suffix: "%", note: "30 days" }, ]; const SERIES = [32, 38, 35, 46, 44, 58, 54, 67, 72, 69, 84, 96]; const BARS = [ { label: "Mon", value: 40 }, { label: "Tue", value: 72 }, { label: "Wed", value: 55 }, { label: "Thu", value: 90 }, { label: "Fri", value: 64 }, ]; export default function DashboardHeroExample() { return ( <div className="h-full overflow-y-auto"> <DashboardHero headingAs="h3" eyebrow="Sample numbers" title="See the whole business on one screen" stats={STATS} series={SERIES} seriesLabel="Revenue, last 12 months" bars={BARS} appName="Northwind / Overview" className="min-h-full bg-[#0b0a14] text-white" > <a href="#start" className="rounded-lg bg-white px-4 py-2 text-sm font-medium text-[#0b0a14]"> Open the demo </a> </DashboardHero> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | — | The headline. |
| stats | DashboardHeroStat[] | — | Tiles along the top. Each has label and value, plus optional prefix, suffix and note. |
| series | number[] | — | The line chart, oldest first. Two values or more. |
| seriesLabel | string | "Trend" | Names the chart, for screen readers too. |
| bars | { label, value }[] | — | The bar chart. Leave out for a wider line chart. |
| appName | string | "Overview" | Name in the window bar. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| children | ReactNode | — | Buttons or links under the text. |
| color | string | "#a78bfa" | Chart colour. |
| tile | string | "#15122a" | Colour of the dashboard panel. |
| headingAs | ElementType | "h1" | Heading tag. Use h2 if the page already has an h1. |
| className | string | "bg-[#0b0a14] text-white" | Set the background and text colour here. |