Empty No Data
Original · freeempty data state with illustration
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/empty-no-data.json"use client";
import type { KeyboardEvent } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type TabId = "npm" | "script" | "http";
type Snippet = {
id: TabId;
label: string;
code: string;
};
type Step = {
id: string;
title: string;
detail: string;
};
type Point = {
day: string;
value: number;
};
const SNIPPETS: Snippet[] = [
{
id: "npm",
label: "npm",
code: `npm i @trailhead/analytics
import { trailhead } from "@trailhead/analytics";
trailhead.init({ key: "th_live_9f2c4a" });
trailhead.track("signup_completed");`,
},
{
id: "script",
label: "Script tag",
code: `<script
src="https://cdn.trailhead.dev/v2/th.js"
data-key="th_live_9f2c4a"
defer
></script>`,
},
{
id: "http",
label: "HTTP API",
code: `curl https://api.trailhead.dev/v2/events \\
-H "Authorization: Bearer th_live_9f2c4a" \\
-H "Content-Type: application/json" \\
-d '{"event":"signup_completed","user":"u_1042"}'`,
},
];
const STEPS: Step[] = [
{
id: "install",
title: "Install the SDK",
detail: "4.1 kB gzipped, no cookies, no consent banner required.",
},
{
id: "event",
title: "Send your first event",
detail: "Page views are automatic. Call track() for everything else.",
},
{
id: "verify",
title: "Verify the live stream",
detail: "Watch events land in the debugger before you ship to production.",
},
{
id: "invite",
title: "Invite your team",
detail: "Dashboards and funnels stay shared from the very first event.",
},
];
const DEMO: Point[] = [
{ day: "Mon", value: 1240 },
{ day: "Tue", value: 1890 },
{ day: "Wed", value: 1560 },
{ day: "Thu", value: 2310 },
{ day: "Fri", value: 2740 },
{ day: "Sat", value: 1180 },
{ day: "Sun", value: 3120 },
];
const GHOST: number[] = [0.34, 0.55, 0.42, 0.66, 0.78, 0.5, 0.86];
const AXIS_MAX = 3200;
const PLOT_TOP = 12;
const PLOT_BOTTOM = 138;
const PLOT_H = PLOT_BOTTOM - PLOT_TOP;
const BAR_W = 22;
const SLOT_W = 42;
const FIRST_X = 46;
const TICKS: number[] = [0, 1000, 2000, 3000];
function yFor(value: number): number {
return PLOT_BOTTOM - (value / AXIS_MAX) * PLOT_H;
}
function barX(index: number): number {
return FIRST_X + index * SLOT_W;
}
function tickLabel(value: number): string {
return value === 0 ? "0" : `${value / 1000}k`;
}
function formatCount(value: number): string {
return value.toLocaleString("en-US");
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
}
function CopyIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<rect x="9" y="9" width="11" height="11" rx="2.5" />
<path d="M15 5.5A2.5 2.5 0 0012.5 3h-7A2.5 2.5 0 003 5.5v7A2.5 2.5 0 005.5 15" />
</svg>
);
}
function BookIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="M4 5.5A2.5 2.5 0 016.5 3H19v15H6.5A2.5 2.5 0 004 20.5z" />
<path d="M4 20.5A2.5 2.5 0 016.5 18H19v3H6.5" />
</svg>
);
}
function EmptyTrayIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 64 64"
fill="none"
aria-hidden="true"
className={className}
>
<ellipse
cx="32"
cy="16"
rx="19"
ry="7.5"
stroke="currentColor"
strokeWidth={2.5}
strokeDasharray="5 6"
/>
<path
d="M13 16v14c0 4.1 8.5 7.5 19 7.5s19-3.4 19-7.5V16"
stroke="currentColor"
strokeWidth={2.5}
strokeDasharray="5 6"
strokeLinecap="round"
/>
<path
d="M13 30v14c0 4.1 8.5 7.5 19 7.5s19-3.4 19-7.5V30"
stroke="currentColor"
strokeWidth={2.5}
strokeDasharray="5 6"
strokeLinecap="round"
/>
</svg>
);
}
export default function EmptyNoData() {
const reduce = useReducedMotion();
const headingId = useId();
const switchId = useId();
const chartTitleId = useId();
const statusId = useId();
const panelId = useId();
const [demo, setDemo] = useState<boolean>(false);
const [tab, setTab] = useState<TabId>("npm");
const [done, setDone] = useState<string[]>(["install"]);
const [copied, setCopied] = useState<boolean>(false);
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const copyTimer = useRef<number | null>(null);
useEffect(() => {
return () => {
if (copyTimer.current !== null) {
window.clearTimeout(copyTimer.current);
}
};
}, []);
const active = useMemo<Snippet>(() => {
const found = SNIPPETS.find((s) => s.id === tab);
return found ?? SNIPPETS[0];
}, [tab]);
const completed = done.length;
const progress = Math.round((completed / STEPS.length) * 100);
const allDone = completed === STEPS.length;
const totals = useMemo(() => {
const views = DEMO.reduce((sum, p) => sum + p.value, 0);
return { views, visitors: 3486, session: "2m 41s" };
}, []);
const toggleStep = useCallback((id: string) => {
setDone((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}, []);
const copySnippet = useCallback(async () => {
try {
await navigator.clipboard.writeText(active.code);
setCopied(true);
if (copyTimer.current !== null) {
window.clearTimeout(copyTimer.current);
}
copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
}, [active.code]);
const onTabKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const keys = ["ArrowRight", "ArrowLeft", "Home", "End"];
if (!keys.includes(event.key)) return;
event.preventDefault();
let next = index;
if (event.key === "ArrowRight") next = (index + 1) % SNIPPETS.length;
if (event.key === "ArrowLeft")
next = (index - 1 + SNIPPETS.length) % SNIPPETS.length;
if (event.key === "Home") next = 0;
if (event.key === "End") next = SNIPPETS.length - 1;
setTab(SNIPPETS[next].id);
tabRefs.current[next]?.focus();
},
[],
);
const fade = reduce
? { initial: false as const, animate: { opacity: 1 } }
: {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -6 },
transition: { duration: 0.26, ease: [0.22, 1, 0.36, 1] as const },
};
return (
<section
aria-labelledby={headingId}
className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-28 dark:bg-slate-950 dark:text-slate-100"
>
<style>{`
@keyframes endat-ghost {
0%, 100% { opacity: .5; }
50% { opacity: .85; }
}
@keyframes endat-drift {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes endat-sweep {
0% { transform: translateX(-120%); }
100% { transform: translateX(220%); }
}
.endat-ghost { animation: endat-ghost 3.2s ease-in-out infinite; }
.endat-drift { animation: endat-drift 6s ease-in-out infinite; }
.endat-sweep { animation: endat-sweep 3.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.endat-ghost, .endat-drift, .endat-sweep { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-0 h-64 w-[42rem] -translate-x-1/2 rounded-full bg-indigo-400/10 blur-3xl dark:bg-indigo-500/10"
/>
<div className="relative mx-auto max-w-5xl">
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-end">
<div>
<p className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<span
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full bg-amber-500"
/>
Awaiting first event
</p>
<h2
id={headingId}
className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white"
>
Trailhead Analytics
</h2>
<p className="mt-2 max-w-lg text-sm text-slate-600 sm:text-base dark:text-slate-400">
This workspace has no traffic data yet. Once the SDK sends its
first event, charts fill in automatically — usually within 60
seconds.
</p>
</div>
<div className="flex items-center gap-3">
<label
htmlFor={switchId}
className="cursor-pointer select-none text-sm font-medium text-slate-700 dark:text-slate-300"
>
Sample data
</label>
<button
id={switchId}
type="button"
role="switch"
aria-checked={demo}
aria-describedby={statusId}
onClick={() => setDemo((v) => !v)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
demo
? "border-indigo-600 bg-indigo-600"
: "border-slate-300 bg-slate-200 dark:border-slate-700 dark:bg-slate-800"
}`}
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
demo ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
</div>
<p id={statusId} role="status" aria-live="polite" className="sr-only">
{demo
? `Showing sample data: ${formatCount(totals.views)} page views across 7 days.`
: "No data collected yet. Chart is empty."}
</p>
<div className="mt-8 grid gap-6 lg:grid-cols-5">
{/* Chart / illustration */}
<div className="lg:col-span-3">
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between gap-3">
<div>
<h3
id={chartTitleId}
className="text-sm font-semibold text-slate-900 dark:text-white"
>
Page views
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
Last 7 days
</p>
</div>
{demo ? (
<span className="rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-500/15 dark:text-amber-300">
Sample
</span>
) : (
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
Empty
</span>
)}
</div>
<div className="relative mt-4">
<svg
viewBox="0 0 340 170"
role="img"
aria-labelledby={chartTitleId}
className="h-auto w-full"
>
{/* grid + axis */}
<g
className="text-slate-200 dark:text-slate-800"
stroke="currentColor"
strokeWidth={1}
>
{TICKS.map((t) => (
<line
key={`grid-${t}`}
x1={36}
x2={330}
y1={yFor(t)}
y2={yFor(t)}
strokeDasharray={t === 0 ? undefined : "3 5"}
/>
))}
</g>
<g
className="fill-slate-400 dark:fill-slate-500"
fontSize={9}
textAnchor="end"
>
{TICKS.map((t) => (
<text key={`tick-${t}`} x={29} y={yFor(t) + 3}>
{tickLabel(t)}
</text>
))}
</g>
<g
className="fill-slate-400 dark:fill-slate-500"
fontSize={9}
textAnchor="middle"
>
{DEMO.map((p, i) => (
<text key={p.day} x={barX(i) + BAR_W / 2} y={154}>
{p.day}
</text>
))}
</g>
{/* bars */}
{demo
? DEMO.map((p, i) => {
const h = (p.value / AXIS_MAX) * PLOT_H;
return (
<motion.rect
key={`bar-${p.day}`}
x={barX(i)}
y={PLOT_BOTTOM - h}
width={BAR_W}
height={h}
rx={4}
className="fill-indigo-500 dark:fill-indigo-400"
style={{
transformBox: "fill-box",
transformOrigin: "bottom",
}}
initial={reduce ? false : { scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={
reduce
? undefined
: {
duration: 0.45,
delay: 0.05 + i * 0.06,
ease: [0.22, 1, 0.36, 1],
}
}
/>
);
})
: GHOST.map((f, i) => (
<rect
key={`ghost-${DEMO[i].day}`}
x={barX(i)}
y={PLOT_BOTTOM - f * PLOT_H}
width={BAR_W}
height={f * PLOT_H}
rx={4}
className="endat-ghost fill-slate-100 stroke-slate-300 dark:fill-slate-800/60 dark:stroke-slate-700"
strokeWidth={1.5}
strokeDasharray="4 5"
style={{ animationDelay: `${i * 0.18}s` }}
/>
))}
{/* baseline */}
<line
x1={36}
x2={330}
y1={PLOT_BOTTOM}
y2={PLOT_BOTTOM}
className="stroke-slate-300 dark:stroke-slate-700"
strokeWidth={1.5}
/>
</svg>
<AnimatePresence initial={false}>
{!demo ? (
<motion.div
key="overlay"
{...fade}
className="absolute inset-0 flex flex-col items-center justify-center rounded-xl bg-white/70 backdrop-blur-[2px] dark:bg-slate-900/70"
>
<EmptyTrayIcon className="endat-drift h-12 w-12 text-slate-300 dark:text-slate-600" />
<p className="mt-3 text-sm font-semibold text-slate-800 dark:text-slate-100">
No data yet
</p>
<p className="mt-1 max-w-[16rem] text-center text-xs text-slate-500 dark:text-slate-400">
Nothing has been tracked for this workspace. Finish
setup, or flip on sample data to see the shape of it.
</p>
<button
type="button"
onClick={() => setDemo(true)}
className="mt-4 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 shadow-sm transition hover:border-indigo-400 hover:text-indigo-600 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-900 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
Show sample data
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
<dl className="mt-5 grid grid-cols-3 gap-3 border-t border-slate-200 pt-5 dark:border-slate-800">
<Stat
label="Page views"
value={demo ? formatCount(totals.views) : "—"}
muted={!demo}
/>
<Stat
label="Visitors"
value={demo ? formatCount(totals.visitors) : "—"}
muted={!demo}
/>
<Stat
label="Median session"
value={demo ? totals.session : "—"}
muted={!demo}
/>
</dl>
</div>
</div>
{/* Setup checklist */}
<div className="lg:col-span-2">
<div className="flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
Finish setup
</h3>
<p className="text-xs font-medium tabular-nums text-slate-500 dark:text-slate-400">
{completed} of {STEPS.length}
</p>
</div>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
aria-label="Setup progress"
className="relative mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
<motion.div
className={`h-full rounded-full ${allDone ? "bg-emerald-500" : "bg-indigo-500"}`}
initial={false}
animate={{ width: `${progress}%` }}
transition={
reduce ? { duration: 0 } : { duration: 0.4, ease: "easeOut" }
}
/>
{!allDone ? (
<span
aria-hidden="true"
className="endat-sweep pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/70 to-transparent dark:via-white/20"
/>
) : null}
</div>
<ul className="mt-5 space-y-2">
{STEPS.map((step) => {
const checked = done.includes(step.id);
return (
<li key={step.id}>
<label className="group flex cursor-pointer items-start gap-3 rounded-xl border border-transparent p-2.5 transition hover:border-slate-200 hover:bg-slate-50 dark:hover:border-slate-800 dark:hover:bg-slate-800/50">
<input
type="checkbox"
checked={checked}
onChange={() => toggleStep(step.id)}
className="peer sr-only"
/>
<span
aria-hidden="true"
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-900 ${
checked
? "border-emerald-500 bg-emerald-500 text-white"
: "border-slate-300 bg-white text-transparent dark:border-slate-600 dark:bg-slate-900"
}`}
>
<CheckIcon className="h-3 w-3" />
</span>
<span className="min-w-0">
<span
className={`block text-sm font-medium transition ${
checked
? "text-slate-400 line-through dark:text-slate-500"
: "text-slate-900 dark:text-slate-100"
}`}
>
{step.title}
</span>
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
{step.detail}
</span>
</span>
</label>
</li>
);
})}
</ul>
<AnimatePresence initial={false}>
{allDone ? (
<motion.p
key="ready"
{...fade}
className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs font-medium text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
>
Setup complete. Your first events should appear here within a
minute of real traffic.
</motion.p>
) : null}
</AnimatePresence>
<div className="mt-auto flex flex-wrap gap-2 pt-5">
<a
href="https://trailhead.dev/docs/quickstart"
target="_blank"
rel="noopener"
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 shadow-sm transition hover:border-indigo-400 hover:text-indigo-600 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-900 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
<BookIcon className="h-3.5 w-3.5" />
Read the quickstart
</a>
</div>
</div>
</div>
</div>
{/* Install snippet */}
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
Send your first event
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
Paste this into your app. The key below is scoped to this
workspace.
</p>
</div>
<div
role="tablist"
aria-label="Installation method"
className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-800/60"
>
{SNIPPETS.map((s, i) => {
const selected = s.id === tab;
return (
<button
key={s.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
id={`${panelId}-tab-${s.id}`}
type="button"
role="tab"
aria-selected={selected}
aria-controls={panelId}
tabIndex={selected ? 0 : -1}
onClick={() => setTab(s.id)}
onKeyDown={(e) => onTabKeyDown(e, i)}
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
selected
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{s.label}
</button>
);
})}
</div>
</div>
<div
id={panelId}
role="tabpanel"
tabIndex={0}
aria-labelledby={`${panelId}-tab-${active.id}`}
className="relative mt-4 rounded-xl border border-slate-200 bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-800 dark:bg-slate-950"
>
<button
type="button"
onClick={copySnippet}
className="absolute right-2.5 top-2.5 z-10 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-600 shadow-sm transition hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-slate-950"
>
{copied ? (
<CheckIcon className="h-3.5 w-3.5 text-emerald-500" />
) : (
<CopyIcon className="h-3.5 w-3.5" />
)}
{copied ? "Copied" : "Copy"}
</button>
<div className="overflow-x-auto p-4 pr-24">
<pre className="text-xs leading-relaxed text-slate-700 dark:text-slate-300">
<code>{active.code}</code>
</pre>
</div>
</div>
<p role="status" aria-live="polite" className="sr-only">
{copied ? "Snippet copied to clipboard." : ""}
</p>
</div>
</div>
</section>
);
}
function Stat({
label,
value,
muted,
}: {
label: string;
value: string;
muted: boolean;
}) {
return (
<div>
<dt className="text-xs text-slate-500 dark:text-slate-400">{label}</dt>
<dd
className={`mt-0.5 text-lg font-semibold tabular-nums ${
muted
? "text-slate-300 dark:text-slate-600"
: "text-slate-900 dark:text-white"
}`}
>
{value}
</dd>
</div>
);
}Dependencies
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 quoteSimilar components
Browse all →
Empty No Results
Originalno results with clear filters

Empty Cart
Originalempty shopping cart state

Empty Inbox
Originalempty inbox / all caught up

Empty First Run
Originalfirst-run get started empty state

Empty Notifications
Originalno notifications state

Empty Error
Originalsomething went wrong empty state

Empty No Connection
Originalno connection empty state

Empty Folder
Originalempty folder with upload CTA

Empty Success
Originalsuccess / done empty state

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

