With Info Pagination
Original · freepagination with results info
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/page-with-info.json"use client";
import {
useId,
useMemo,
useState,
type ChangeEvent,
type KeyboardEvent,
} from "react";
import { motion, useReducedMotion, type Variants } from "motion/react";
type TxStatus = "completed" | "pending" | "failed" | "refunded";
type Transaction = {
id: string;
reference: string;
merchant: string;
method: string;
date: string;
amount: number;
status: TxStatus;
};
const TRANSACTIONS: Transaction[] = [
{ id: "t01", reference: "TXN-9042", merchant: "Figma", method: "Visa •• 4242", date: "Jul 14, 2026", amount: 45.0, status: "completed" },
{ id: "t02", reference: "TXN-9038", merchant: "Vercel", method: "Amex •• 1009", date: "Jul 14, 2026", amount: 20.0, status: "completed" },
{ id: "t03", reference: "TXN-9031", merchant: "Linear", method: "Visa •• 4242", date: "Jul 13, 2026", amount: 96.0, status: "completed" },
{ id: "t04", reference: "TXN-9024", merchant: "Notion", method: "Mastercard •• 8830", date: "Jul 13, 2026", amount: 120.0, status: "pending" },
{ id: "t05", reference: "TXN-9019", merchant: "GitHub", method: "Visa •• 4242", date: "Jul 12, 2026", amount: 21.0, status: "completed" },
{ id: "t06", reference: "TXN-9012", merchant: "OpenAI", method: "Amex •• 1009", date: "Jul 12, 2026", amount: 200.0, status: "completed" },
{ id: "t07", reference: "TXN-9007", merchant: "Anthropic", method: "Visa •• 4242", date: "Jul 11, 2026", amount: 100.0, status: "completed" },
{ id: "t08", reference: "TXN-8998", merchant: "Amazon Web Services", method: "ACH transfer", date: "Jul 11, 2026", amount: 842.16, status: "pending" },
{ id: "t09", reference: "TXN-8991", merchant: "Cloudflare", method: "Mastercard •• 8830", date: "Jul 10, 2026", amount: 25.0, status: "completed" },
{ id: "t10", reference: "TXN-8984", merchant: "Datadog", method: "ACH transfer", date: "Jul 10, 2026", amount: 310.0, status: "completed" },
{ id: "t11", reference: "TXN-8977", merchant: "Sentry", method: "Visa •• 4242", date: "Jul 09, 2026", amount: 29.0, status: "failed" },
{ id: "t12", reference: "TXN-8970", merchant: "Twilio", method: "Amex •• 1009", date: "Jul 09, 2026", amount: 74.55, status: "completed" },
{ id: "t13", reference: "TXN-8963", merchant: "Slack", method: "Visa •• 4242", date: "Jul 08, 2026", amount: 87.5, status: "completed" },
{ id: "t14", reference: "TXN-8956", merchant: "Zoom", method: "Mastercard •• 8830", date: "Jul 08, 2026", amount: 15.99, status: "refunded" },
{ id: "t15", reference: "TXN-8949", merchant: "Adobe Creative Cloud", method: "Visa •• 4242", date: "Jul 07, 2026", amount: 59.99, status: "completed" },
{ id: "t16", reference: "TXN-8942", merchant: "Google Workspace", method: "ACH transfer", date: "Jul 07, 2026", amount: 144.0, status: "completed" },
{ id: "t17", reference: "TXN-8935", merchant: "Stripe", method: "Amex •• 1009", date: "Jul 06, 2026", amount: 12.4, status: "completed" },
{ id: "t18", reference: "TXN-8928", merchant: "DigitalOcean", method: "Visa •• 4242", date: "Jul 06, 2026", amount: 48.0, status: "completed" },
{ id: "t19", reference: "TXN-8921", merchant: "Fly.io", method: "Mastercard •• 8830", date: "Jul 05, 2026", amount: 33.2, status: "pending" },
{ id: "t20", reference: "TXN-8914", merchant: "Retool", method: "Visa •• 4242", date: "Jul 05, 2026", amount: 250.0, status: "completed" },
{ id: "t21", reference: "TXN-8907", merchant: "Airtable", method: "Amex •• 1009", date: "Jul 04, 2026", amount: 60.0, status: "completed" },
{ id: "t22", reference: "TXN-8900", merchant: "Calendly", method: "Visa •• 4242", date: "Jul 04, 2026", amount: 16.0, status: "completed" },
{ id: "t23", reference: "TXN-8893", merchant: "1Password", method: "Mastercard •• 8830", date: "Jul 03, 2026", amount: 95.88, status: "completed" },
{ id: "t24", reference: "TXN-8886", merchant: "Miro", method: "Visa •• 4242", date: "Jul 03, 2026", amount: 40.0, status: "failed" },
{ id: "t25", reference: "TXN-8879", merchant: "Loom", method: "Amex •• 1009", date: "Jul 02, 2026", amount: 150.0, status: "completed" },
{ id: "t26", reference: "TXN-8872", merchant: "Framer", method: "Visa •• 4242", date: "Jul 02, 2026", amount: 30.0, status: "completed" },
{ id: "t27", reference: "TXN-8865", merchant: "Webflow", method: "Mastercard •• 8830", date: "Jul 01, 2026", amount: 39.0, status: "refunded" },
{ id: "t28", reference: "TXN-8858", merchant: "Postmark", method: "Visa •• 4242", date: "Jul 01, 2026", amount: 55.0, status: "completed" },
{ id: "t29", reference: "TXN-8851", merchant: "Raycast", method: "Amex •• 1009", date: "Jun 30, 2026", amount: 96.0, status: "completed" },
{ id: "t30", reference: "TXN-8844", merchant: "Descript", method: "Visa •• 4242", date: "Jun 30, 2026", amount: 288.0, status: "completed" },
{ id: "t31", reference: "TXN-8837", merchant: "Fathom Analytics", method: "Mastercard •• 8830", date: "Jun 29, 2026", amount: 54.0, status: "completed" },
{ id: "t32", reference: "TXN-8830", merchant: "Arc Browser", method: "Visa •• 4242", date: "Jun 29, 2026", amount: 18.0, status: "completed" },
];
const PAGE_SIZE_OPTIONS = [6, 10, 15] as const;
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const STATUS_META: Record<
TxStatus,
{ label: string; badge: string; dot: string }
> = {
completed: {
label: "Completed",
badge:
"bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
dot: "bg-emerald-500",
},
pending: {
label: "Pending",
badge:
"bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
dot: "bg-amber-500",
},
failed: {
label: "Failed",
badge:
"bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
dot: "bg-rose-500",
},
refunded: {
label: "Refunded",
badge:
"bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/25",
dot: "bg-sky-500",
},
};
function buildPageList(
current: number,
total: number,
): Array<number | "gap"> {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const delta = 1;
const pages: number[] = [];
for (let i = 1; i <= total; i += 1) {
if (
i === 1 ||
i === total ||
(i >= current - delta && i <= current + delta)
) {
pages.push(i);
}
}
const out: Array<number | "gap"> = [];
let prev = 0;
for (const p of pages) {
if (prev) {
if (p - prev === 2) {
out.push(prev + 1);
} else if (p - prev > 2) {
out.push("gap");
}
}
out.push(p);
prev = p;
}
return out;
}
const rowVariants: Variants = {
hidden: { opacity: 0, y: 8 },
show: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: Math.min(i, 9) * 0.035,
duration: 0.3,
ease: [0.16, 1, 0.3, 1],
},
}),
};
function ChevronLeft() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M12.5 15 7.5 10l5-5"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronRight() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M7.5 5 12.5 10l-5 5"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronsLeft() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M10.5 15 5.5 10l5-5M15 15l-5-5 5-5"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronsRight() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M9.5 5 14.5 10l-5 5M5 5l5 5-5 5"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<circle cx="9" cy="9" r="5.25" stroke="currentColor" strokeWidth="1.7" />
<path
d="m13.5 13.5 3 3"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
<path
d="m6 6 8 8M14 6l-8 8"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function ReceiptIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
<path
d="M6 3.5h12v17l-2.4-1.4-2.4 1.4-2.4-1.4-2.4 1.4L6 20.5v-17Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M9 8h6M9 11.5h6M9 15h3.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
export default function PageWithInfo() {
const reduce = useReducedMotion();
const searchId = useId();
const pageSizeId = useId();
const [query, setQuery] = useState("");
const [pageSize, setPageSize] = useState<number>(PAGE_SIZE_OPTIONS[0]);
const [page, setPage] = useState(1);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return TRANSACTIONS;
return TRANSACTIONS.filter((t) => {
const label = STATUS_META[t.status].label.toLowerCase();
return (
t.merchant.toLowerCase().includes(q) ||
t.reference.toLowerCase().includes(q) ||
t.method.toLowerCase().includes(q) ||
label.includes(q)
);
});
}, [query]);
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(Math.max(page, 1), totalPages);
const startIndex = (currentPage - 1) * pageSize;
const visible = filtered.slice(startIndex, startIndex + pageSize);
const firstShown = total === 0 ? 0 : startIndex + 1;
const lastShown = Math.min(startIndex + pageSize, total);
const pageSum = visible.reduce((acc, t) => acc + t.amount, 0);
const pageList = buildPageList(currentPage, totalPages);
function goToPage(next: number) {
setPage(Math.min(Math.max(next, 1), totalPages));
}
function onSearch(e: ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
setPage(1);
}
function onPageSizeChange(e: ChangeEvent<HTMLSelectElement>) {
setPageSize(Number(e.target.value));
setPage(1);
}
function onNavKeyDown(e: KeyboardEvent<HTMLElement>) {
if (e.key === "ArrowLeft") {
e.preventDefault();
goToPage(currentPage - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
goToPage(currentPage + 1);
} else if (e.key === "Home") {
e.preventDefault();
goToPage(1);
} else if (e.key === "End") {
e.preventDefault();
goToPage(totalPages);
}
}
const arrowBtn =
"inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white disabled:hover:text-slate-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-zinc-800 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white dark:disabled:hover:bg-zinc-900 dark:focus-visible:ring-offset-zinc-950";
return (
<section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-zinc-950 dark:text-zinc-100">
<style>{`
@keyframes pwinfo-sheen {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.pwinfo-accent {
background-image: linear-gradient(90deg, #6366f1, #0ea5e9, #10b981, #6366f1);
background-size: 200% 100%;
animation: pwinfo-sheen 7s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.pwinfo-accent { animation: none; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<div className="mb-8 flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Billing
</span>
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">
Transaction history
</h2>
<p className="max-w-xl text-sm text-slate-500 dark:text-zinc-400">
Every charge across your connected subscriptions and infrastructure
vendors, refreshed hourly.
</p>
</div>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="pwinfo-accent h-1 w-full" aria-hidden="true" />
{/* Toolbar */}
<div className="flex flex-col gap-4 border-b border-slate-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800">
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400">
<ReceiptIcon />
</span>
<div>
<p className="text-sm font-semibold leading-tight">
All transactions
</p>
<p className="text-xs text-slate-500 dark:text-zinc-400">
{TRANSACTIONS.length} total records
</p>
</div>
</div>
<div className="relative w-full sm:w-72">
<label htmlFor={searchId} className="sr-only">
Search transactions
</label>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-zinc-500">
<SearchIcon />
</span>
<input
id={searchId}
type="search"
value={query}
onChange={onSearch}
placeholder="Search merchant, ref, status…"
className="w-full rounded-lg border border-slate-200 bg-slate-50 py-2 pl-9 pr-9 text-sm text-slate-900 placeholder:text-slate-400 transition-colors focus:border-indigo-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-indigo-500"
/>
{query.length > 0 && (
<button
type="button"
onClick={() => {
setQuery("");
setPage(1);
}}
className="absolute right-2.5 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-200 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
aria-label="Clear search"
>
<CloseIcon />
</button>
)}
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] border-collapse text-left text-sm">
<caption className="sr-only">
List of transactions, paginated
</caption>
<thead>
<tr className="border-b border-slate-200 text-xs uppercase tracking-wider text-slate-500 dark:border-zinc-800 dark:text-zinc-400">
<th scope="col" className="px-5 py-3 font-medium">
Reference
</th>
<th scope="col" className="px-5 py-3 font-medium">
Merchant
</th>
<th
scope="col"
className="hidden px-5 py-3 font-medium md:table-cell"
>
Date
</th>
<th scope="col" className="px-5 py-3 text-right font-medium">
Amount
</th>
<th scope="col" className="px-5 py-3 font-medium">
Status
</th>
</tr>
</thead>
<tbody>
{visible.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<p className="text-sm font-medium text-slate-700 dark:text-zinc-200">
No transactions match “{query}”.
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">
Try a different merchant name or reference.
</p>
</td>
</tr>
) : (
visible.map((t, i) => {
const meta = STATUS_META[t.status];
return (
<motion.tr
key={`${currentPage}-${pageSize}-${t.id}`}
custom={i}
variants={reduce ? undefined : rowVariants}
initial={reduce ? false : "hidden"}
animate={reduce ? undefined : "show"}
className="border-b border-slate-100 transition-colors last:border-0 hover:bg-slate-50 dark:border-zinc-800/60 dark:hover:bg-zinc-800/40"
>
<td className="px-5 py-3.5">
<span className="font-mono text-xs text-slate-500 dark:text-zinc-400">
{t.reference}
</span>
</td>
<td className="px-5 py-3.5">
<span className="block font-medium text-slate-900 dark:text-zinc-100">
{t.merchant}
</span>
<span className="block text-xs text-slate-400 dark:text-zinc-500">
{t.method}
</span>
</td>
<td className="hidden px-5 py-3.5 text-slate-500 md:table-cell dark:text-zinc-400">
{t.date}
</td>
<td className="px-5 py-3.5 text-right font-mono tabular-nums text-slate-900 dark:text-zinc-100">
{t.status === "refunded" ? "−" : ""}
{usd.format(t.amount)}
</td>
<td className="px-5 py-3.5">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${meta.badge}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${meta.dot}`}
aria-hidden="true"
/>
{meta.label}
</span>
</td>
</motion.tr>
);
})
)}
</tbody>
</table>
</div>
{/* Footer: results info + pagination */}
<div className="flex flex-col gap-4 border-t border-slate-200 px-5 py-4 lg:flex-row lg:items-center lg:justify-between dark:border-zinc-800">
<div>
<p
className="text-sm text-slate-600 dark:text-zinc-300"
aria-live="polite"
>
Showing{" "}
<span className="font-semibold text-slate-900 dark:text-zinc-100">
{firstShown}–{lastShown}
</span>{" "}
of{" "}
<span className="font-semibold text-slate-900 dark:text-zinc-100">
{total}
</span>{" "}
{total === 1 ? "transaction" : "transactions"}
</p>
{total > 0 && (
<p className="mt-0.5 text-xs text-slate-400 dark:text-zinc-500">
{usd.format(pageSum)} on this page
</p>
)}
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6">
<div className="flex items-center gap-2">
<label
htmlFor={pageSizeId}
className="text-sm text-slate-500 dark:text-zinc-400"
>
Rows
</label>
<select
id={pageSizeId}
value={pageSize}
onChange={onPageSizeChange}
className="rounded-lg border border-slate-200 bg-white py-1.5 pl-2.5 pr-7 text-sm 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-zinc-800 dark:bg-zinc-950 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-950"
>
{PAGE_SIZE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<nav
aria-label="Pagination"
onKeyDown={onNavKeyDown}
className="flex items-center gap-1"
>
<button
type="button"
onClick={() => goToPage(1)}
disabled={currentPage === 1}
className={arrowBtn}
aria-label="Go to first page"
>
<ChevronsLeft />
</button>
<button
type="button"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
className={arrowBtn}
aria-label="Go to previous page"
>
<ChevronLeft />
</button>
<ul className="flex items-center gap-1">
{pageList.map((item, idx) =>
item === "gap" ? (
<li
key={`gap-${idx}`}
aria-hidden="true"
className="inline-flex h-9 w-9 items-center justify-center text-sm text-slate-400 dark:text-zinc-600"
>
…
</li>
) : (
<li key={item}>
<button
type="button"
onClick={() => goToPage(item)}
aria-current={
item === currentPage ? "page" : undefined
}
aria-label={
item === currentPage
? `Page ${item}, current page`
: `Go to page ${item}`
}
className={
"inline-flex h-9 min-w-9 items-center justify-center rounded-lg px-2 text-sm font-medium tabular-nums 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-zinc-950 " +
(item === currentPage
? "bg-indigo-600 text-white shadow-sm hover:bg-indigo-600"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white")
}
>
{item}
</button>
</li>
),
)}
</ul>
<button
type="button"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
className={arrowBtn}
aria-label="Go to next page"
>
<ChevronRight />
</button>
<button
type="button"
onClick={() => goToPage(totalPages)}
disabled={currentPage === totalPages}
className={arrowBtn}
aria-label="Go to last page"
>
<ChevronsRight />
</button>
</nav>
</div>
</div>
</div>
</div>
</section>
);
}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 →
Basic Pagination
Originalbasic numbered pagination

Rounded Pagination
Originalrounded pagination buttons

Arrows Pagination
Originalprev/next arrow pagination

Compact Pagination
Originalcompact pagination with ellipsis

Input Pagination
Originalpagination with a go-to-page input

Load More Pagination
Originalload-more button with progress

Dots Pagination
Originaldot pagination indicators

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.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

