Kanban Backlog
Original · freebacklog list with priorities
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/kanban-backlog.json"use client";
import type { KeyboardEvent } from "react";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Priority = "critical" | "high" | "medium" | "low";
type Status = "triage" | "ready" | "blocked";
type Story = {
id: string;
key: string;
title: string;
detail: string;
priority: Priority;
status: Status;
points: number;
epic: string;
assignee: string;
comments: number;
};
const PRIORITY_ORDER: Record<Priority, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
};
const PRIORITY_META: Record<
Priority,
{ label: string; chip: string; bar: string; arrows: number }
> = {
critical: {
label: "Critical",
chip: "bg-rose-100 text-rose-700 ring-rose-600/20 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/25",
bar: "bg-rose-500",
arrows: 3,
},
high: {
label: "High",
chip: "bg-amber-100 text-amber-800 ring-amber-600/20 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/25",
bar: "bg-amber-500",
arrows: 2,
},
medium: {
label: "Medium",
chip: "bg-sky-100 text-sky-700 ring-sky-600/20 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/25",
bar: "bg-sky-500",
arrows: 1,
},
low: {
label: "Low",
chip: "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-500/15 dark:text-slate-400 dark:ring-slate-400/20",
bar: "bg-slate-400",
arrows: 1,
},
};
const STATUS_META: Record<Status, { label: string; chip: string }> = {
triage: {
label: "Needs triage",
chip: "bg-violet-100 text-violet-700 ring-violet-600/20 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/25",
},
ready: {
label: "Ready for sprint",
chip: "bg-emerald-100 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/25",
},
blocked: {
label: "Blocked",
chip: "bg-rose-100 text-rose-700 ring-rose-600/20 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/25",
},
};
const INITIAL_STORIES: Story[] = [
{
id: "s1",
key: "PAY-418",
title: "Retry webhook delivery with exponential backoff",
detail:
"Stripe events fail silently when our worker returns a 502. Queue failed deliveries and retry at 1s, 8s, 64s before paging on-call.",
priority: "critical",
status: "ready",
points: 5,
epic: "Billing reliability",
assignee: "Priya N.",
comments: 7,
},
{
id: "s2",
key: "AUTH-207",
title: "Session cookie drops on Safari 17 after refresh",
detail:
"SameSite=None without Partitioned is rejected in ITP. Add CHIPS attribute and backfill an integration test on the login flow.",
priority: "critical",
status: "blocked",
points: 3,
epic: "Auth hardening",
assignee: "Marcus L.",
comments: 12,
},
{
id: "s3",
key: "SRCH-092",
title: "Typo tolerance for two-character queries",
detail:
"Trigram index skips anything under three chars, so \"ai\" returns nothing. Add a prefix fallback path with a 40ms budget.",
priority: "high",
status: "ready",
points: 8,
epic: "Search quality",
assignee: "Dana O.",
comments: 3,
},
{
id: "s4",
key: "ONB-311",
title: "Skip workspace naming step for invited teammates",
detail:
"Invitees already join a named workspace but still see the naming screen. Cuts three taps from the invited-user funnel.",
priority: "high",
status: "triage",
points: 2,
epic: "Onboarding",
assignee: "Ravi S.",
comments: 1,
},
{
id: "s5",
key: "DASH-540",
title: "Virtualise the activity feed past 200 rows",
detail:
"Feed renders every row on mount; INP crosses 400ms on mid-tier Android. Window the list and cache row heights.",
priority: "high",
status: "ready",
points: 5,
epic: "Performance",
assignee: "Priya N.",
comments: 4,
},
{
id: "s6",
key: "EXP-128",
title: "CSV export loses UTF-8 accents in Excel",
detail:
"Excel assumes CP-1252 without a byte-order mark. Prefix the stream with a BOM and add a fixture for Latin-1 names.",
priority: "medium",
status: "ready",
points: 2,
epic: "Reporting",
assignee: "Dana O.",
comments: 2,
},
{
id: "s7",
key: "A11Y-064",
title: "Focus escapes the command palette on Tab",
detail:
"No focus trap, so Tab walks into the page behind the dialog. Wire a roving trap and restore focus to the trigger on close.",
priority: "medium",
status: "triage",
points: 3,
epic: "Accessibility",
assignee: "Marcus L.",
comments: 5,
},
{
id: "s8",
key: "INFRA-77",
title: "Split the migrations job out of the deploy pipeline",
detail:
"Long migrations block rollbacks for the whole fleet. Run them as a gated pre-deploy job with its own timeout.",
priority: "medium",
status: "blocked",
points: 8,
epic: "Platform",
assignee: "Ravi S.",
comments: 9,
},
{
id: "s9",
key: "DOC-019",
title: "Document the rate-limit headers we actually send",
detail:
"Public docs list X-RateLimit-Reset in seconds; we send an epoch timestamp. Fix the reference and add a curl example.",
priority: "low",
status: "ready",
points: 1,
epic: "Developer docs",
assignee: "Dana O.",
comments: 0,
},
{
id: "s10",
key: "UI-455",
title: "Empty state for saved views has no illustration",
detail:
"The panel drops to a bare sentence. Add the shared empty-state block plus a link to the saved-views guide.",
priority: "low",
status: "triage",
points: 1,
epic: "Design system",
assignee: "Ravi S.",
comments: 2,
},
];
const FILTERS: { value: Priority | "all"; label: string }[] = [
{ value: "all", label: "All" },
{ value: "critical", label: "Critical" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
];
function PriorityArrows({ priority }: { priority: Priority }) {
const { arrows } = PRIORITY_META[priority];
const down = priority === "low";
return (
<span className="inline-flex items-center" aria-hidden="true">
{Array.from({ length: arrows }).map((_, i) => (
<svg
key={i}
viewBox="0 0 12 12"
className={`h-3 w-3 ${down ? "rotate-180" : ""} ${i > 0 ? "-ml-1.5" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 7.5 6 3.5l4 4" />
</svg>
))}
</span>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 16 16"
className={`h-4 w-4 transition-transform duration-200 ${open ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 3.5 10.5 8 6 12.5" />
</svg>
);
}
function CommentIcon() {
return (
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14 9.5a2 2 0 0 1-2 2H6l-3.5 2.5v-2.5H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z" />
</svg>
);
}
function ArrowMoveIcon({ dir }: { dir: "up" | "down" }) {
return (
<svg
viewBox="0 0 16 16"
className={`h-3.5 w-3.5 ${dir === "down" ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8 13V3.5M4 7l4-4 4 4" />
</svg>
);
}
export default function KanbanBacklog() {
const reduce = useReducedMotion();
const headingId = useId();
const listId = useId();
const [stories, setStories] = useState<Story[]>(INITIAL_STORIES);
const [filter, setFilter] = useState<Priority | "all">("all");
const [openId, setOpenId] = useState<string | null>("s1");
const [selected, setSelected] = useState<Set<string>>(() => new Set(["s1", "s3"]));
const [announce, setAnnounce] = useState("");
const rowRefs = useRef<Map<string, HTMLLIElement | null>>(new Map());
const visible = useMemo(
() =>
stories.filter((s) => (filter === "all" ? true : s.priority === filter)),
[stories, filter],
);
const counts = useMemo(() => {
const base: Record<Priority | "all", number> = {
all: stories.length,
critical: 0,
high: 0,
medium: 0,
low: 0,
};
for (const s of stories) base[s.priority] += 1;
return base;
}, [stories]);
const selectedPoints = useMemo(
() =>
stories.reduce((sum, s) => (selected.has(s.id) ? sum + s.points : sum), 0),
[stories, selected],
);
const toggleSelected = useCallback((story: Story) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(story.id)) {
next.delete(story.id);
setAnnounce(`${story.key} removed from sprint draft.`);
} else {
next.add(story.id);
setAnnounce(`${story.key} added to sprint draft.`);
}
return next;
});
}, []);
const move = useCallback(
(id: string, dir: -1 | 1) => {
setStories((prev) => {
const from = prev.findIndex((s) => s.id === id);
const to = from + dir;
if (from < 0 || to < 0 || to >= prev.length) return prev;
const next = [...prev];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
setAnnounce(`${item.key} moved to rank ${to + 1} of ${next.length}.`);
return next;
});
window.requestAnimationFrame(() => rowRefs.current.get(id)?.focus());
},
[],
);
const sortByPriority = useCallback(() => {
setStories((prev) =>
[...prev].sort(
(a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority],
),
);
setAnnounce("Backlog re-ranked by priority, critical first.");
}, []);
const onRowKeyDown = useCallback(
(e: KeyboardEvent<HTMLLIElement>, story: Story) => {
if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
e.preventDefault();
move(story.id, e.key === "ArrowUp" ? -1 : 1);
return;
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const i = visible.findIndex((s) => s.id === story.id);
const nxt = visible[i + (e.key === "ArrowDown" ? 1 : -1)];
if (nxt) rowRefs.current.get(nxt.id)?.focus();
}
},
[move, visible],
);
return (
<section
className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 lg:py-28 dark:bg-slate-950 dark:text-slate-100"
aria-labelledby={headingId}
>
<style>{`
@keyframes kbBacklogRise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
@keyframes kbBacklogPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.72); }
}
.kb-backlog-rise { animation: kbBacklogRise 520ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.kb-backlog-pulse { animation: kbBacklogPulse 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.kb-backlog-rise, .kb-backlog-pulse { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="kb-backlog-rise flex flex-col gap-5 border-b border-slate-200 pb-8 dark:border-slate-800">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Sprint 34 planning
</p>
<h2
id={headingId}
className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl"
>
Product backlog
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Ranked top to bottom. Drag is optional here — use the rank
buttons, or focus a row and press{" "}
<kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
Alt
</kbd>{" "}
+{" "}
<kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
↑
</kbd>
{" / "}
<kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
↓
</kbd>
.
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-right shadow-sm dark:border-slate-800 dark:bg-slate-900">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">
Sprint draft
</p>
<p className="mt-0.5 text-2xl font-semibold tabular-nums">
{selectedPoints}
<span className="ml-1 text-sm font-normal text-slate-500 dark:text-slate-400">
/ 21 pts
</span>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<div
role="group"
aria-label="Filter backlog by priority"
className="flex flex-wrap gap-1.5"
>
{FILTERS.map((f) => {
const active = filter === f.value;
return (
<button
key={f.value}
type="button"
aria-pressed={active}
onClick={() => setFilter(f.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ring-1 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 ${
active
? "bg-slate-900 text-white ring-slate-900 dark:bg-slate-100 dark:text-slate-900 dark:ring-slate-100"
: "bg-white text-slate-600 ring-slate-200 hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-800 dark:hover:bg-slate-800"
}`}
>
{f.label}
<span
className={`tabular-nums ${active ? "opacity-70" : "text-slate-400 dark:text-slate-500"}`}
>
{counts[f.value]}
</span>
</button>
);
})}
</div>
<button
type="button"
onClick={sortByPriority}
className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100 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-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
>
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 13V3M1.5 5.5 4 3l2.5 2.5M12 3v10m2.5-2.5L12 13l-2.5-2.5" />
</svg>
Re-rank by priority
</button>
</div>
</header>
<ul id={listId} className="mt-6 flex flex-col gap-2.5">
<AnimatePresence initial={false} mode="popLayout">
{visible.map((story, index) => {
const p = PRIORITY_META[story.priority];
const st = STATUS_META[story.status];
const open = openId === story.id;
const picked = selected.has(story.id);
const rank = stories.findIndex((s) => s.id === story.id);
return (
<motion.li
key={story.id}
ref={(el: HTMLLIElement | null) => {
rowRefs.current.set(story.id, el);
}}
layout={!reduce}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 420, damping: 34 }
}
tabIndex={0}
onKeyDown={(e) => onRowKeyDown(e, story)}
aria-label={`Rank ${rank + 1}, ${story.key}, ${story.title}, ${p.label} priority`}
className={`group relative overflow-hidden rounded-xl border bg-white shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950 ${
picked
? "border-indigo-300 dark:border-indigo-500/50"
: "border-slate-200 hover:border-slate-300 dark:border-slate-800 dark:hover:border-slate-700"
}`}
>
<span
className={`absolute inset-y-0 left-0 w-1 ${p.bar}`}
aria-hidden="true"
/>
<div className="flex items-start gap-3 py-3 pl-5 pr-3 sm:pr-4">
<span className="mt-1 w-6 shrink-0 text-right text-xs font-medium tabular-nums text-slate-400 dark:text-slate-600">
{rank + 1}
</span>
<label className="mt-0.5 flex shrink-0 cursor-pointer items-center">
<input
type="checkbox"
checked={picked}
onChange={() => toggleSelected(story)}
className="h-4 w-4 cursor-pointer rounded border-slate-300 text-indigo-600 accent-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-600 dark:focus-visible:ring-offset-slate-900"
/>
<span className="sr-only">
Add {story.key} to the sprint draft
</span>
</label>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-mono text-[11px] font-medium tracking-tight text-slate-500 dark:text-slate-400">
{story.key}
</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset ${p.chip}`}
>
<PriorityArrows priority={story.priority} />
{p.label}
</span>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset ${st.chip}`}
>
{story.status === "blocked" && (
<span
className="kb-backlog-pulse h-1.5 w-1.5 rounded-full bg-rose-500 dark:bg-rose-400"
aria-hidden="true"
/>
)}
{st.label}
</span>
</div>
<button
type="button"
onClick={() => setOpenId(open ? null : story.id)}
aria-expanded={open}
aria-controls={`${listId}-panel-${story.id}`}
className="mt-1 flex w-full items-start gap-1.5 rounded text-left text-sm font-medium leading-snug text-slate-900 transition-colors hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-100 dark:hover:text-indigo-300"
>
<span className="mt-0.5 shrink-0 text-slate-400 dark:text-slate-500">
<ChevronIcon open={open} />
</span>
<span className="min-w-0">{story.title}</span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={`${listId}-panel-${story.id}`}
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={
reduce ? { duration: 0 } : { duration: 0.24, ease: [0.22, 1, 0.36, 1] }
}
className="overflow-hidden"
>
<p className="pb-1 pl-5 pt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{story.detail}
</p>
</motion.div>
)}
</AnimatePresence>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 pl-5 text-[11px] text-slate-500 dark:text-slate-400">
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{story.epic}
</span>
<span>{story.assignee}</span>
<span className="inline-flex items-center gap-1">
<CommentIcon />
<span className="tabular-nums">{story.comments}</span>
<span className="sr-only">comments</span>
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 self-center">
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-md bg-slate-100 px-1.5 text-xs font-semibold tabular-nums text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{story.points}
<span className="sr-only"> story points</span>
</span>
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => move(story.id, -1)}
disabled={rank === 0}
aria-label={`Move ${story.key} up one rank`}
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-30 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<ArrowMoveIcon dir="up" />
</button>
<button
type="button"
onClick={() => move(story.id, 1)}
disabled={rank === stories.length - 1}
aria-label={`Move ${story.key} down one rank`}
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-30 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<ArrowMoveIcon dir="down" />
</button>
</div>
</div>
</div>
{index === 2 && filter === "all" && (
<div className="flex items-center gap-2 border-t border-dashed border-indigo-300 bg-indigo-50/60 px-5 py-1.5 text-[11px] font-medium text-indigo-700 dark:border-indigo-500/40 dark:bg-indigo-500/10 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" aria-hidden="true" />
Sprint capacity line — 21 points
</div>
)}
</motion.li>
);
})}
</AnimatePresence>
</ul>
{visible.length === 0 && (
<p className="mt-6 rounded-xl border border-dashed border-slate-300 bg-white px-6 py-10 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
Nothing at this priority. Clear the filter to see the full backlog.
</p>
)}
<p aria-live="polite" className="sr-only">
{announce}
</p>
</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 →
Kanban Board
Originalkanban board with 3 columns and cards

Kanban Task Card
Originaldetailed task card with labels and avatars

Kanban Column
Originalsingle kanban column with add-card

Kanban Swimlane
Originalswimlane grouped board

Kanban Sprint
Originalsprint board with progress

Kanban Compact
Originalcompact mini board

Kanban Labels
Originalboard cards with coloured labels

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.

