First Item Empty State for React and Tailwind CSS (shadcn/ui): an empty list whose placeholder rows become the first real row on Create. It is built to make the moment after Create feel continuous: the row lifts out of the ghost layer, the title types in, the pill lands, the copy folds into a toolbar and the rest collapse. onCreate returns an item or a promise; a rejection shows Try again. Controlled or uncontrolled items, renderItem, keyboard support, a live region, reduced motion, light and dark. Use it for the first screen of projects, clients or files. The included demo creates two projects, fails once and retries, and makes no requests. Requires React only, with no animation library.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds First Item Empty State – Empty List that Turns into Your First Row to your project and fits it to your theme.
Code
How First Item Empty State – Empty List that Turns into Your First Row is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { FirstItemEmptyState } from "@/components/ui/first-item-empty-state";
import type { FirstItem, FirstItemStatus } from "@/components/ui/first-item-empty-state";
type Entry = { slug: string; title: string; status: FirstItemStatus };
const CATALOG: Entry[] = [
{ slug: "website-redesign", title: "Website redesign", status: { label: "Active", tone: "chart-2" } },
{ slug: "mobile-onboarding", title: "Mobile onboarding", status: { label: "Draft", tone: "muted" } },
{ slug: "brand-guidelines", title: "Brand guidelines", status: { label: "Active", tone: "chart-2" } },
{ slug: "customer-portal", title: "Customer portal", status: { label: "Draft", tone: "muted" } },
{ slug: "q1-roadmap", title: "Q1 roadmap", status: { label: "Active", tone: "chart-2" } },
];
const PRESET: FirstItem[] = [
{ id: "preset-brand-guidelines", title: "Brand guidelines", meta: "Created 2 days ago · Mina Park", status: { label: "Active", tone: "chart-2" } },
{ id: "preset-customer-portal", title: "Customer portal", meta: "Created last week · Jonas Weber", status: { label: "Draft", tone: "muted" } },
];
const CYCLE = 10000;
const FIRST_CLICK = 1400;
const RETRY_CLICK = 3100;
const SECOND_CLICK = 4600;
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
function readParams() {
if (typeof window === "undefined") return new URLSearchParams();
return new URLSearchParams(window.location.search);
}
export default function Demo({ autoplay = true }: { autoplay?: boolean }) {
const params = React.useMemo(readParams, []);
const uncontrolled = params.get("mode") === "uncontrolled";
const lock = params.get("lock") === "1";
const forceFail = params.get("fail") === "1";
const delay = params.has("delay") ? Math.max(0, Number(params.get("delay")) || 0) : 350;
const presetCount = Math.min(PRESET.length, Math.max(0, Number(params.get("items")) || 0));
const preset = PRESET.slice(0, presetCount);
const scripted = autoplay && !lock && presetCount === 0;
const [items, setItems] = React.useState(preset);
const [generation, setGeneration] = React.useState(0);
const [stopped, setStopped] = React.useState(false);
const [changes, setChanges] = React.useState(0);
const [imports, setImports] = React.useState(0);
const [selected, setSelected] = React.useState("");
const [failures, setFailures] = React.useState(0);
const [attempts, setAttempts] = React.useState(0);
const [cycle, setCycle] = React.useState(0);
const [motion, setMotion] = React.useState({ ready: false, reduced: true });
const [entered, setEntered] = React.useState(!scripted);
const stageRef = React.useRef(null as HTMLDivElement | null);
const rootRef = React.useRef(null as HTMLDivElement | null);
const created = React.useRef(0);
const createdInCycle = React.useRef(0);
const attemptCount = React.useRef(0);
const attemptsInCycle = React.useRef(0);
const cycleRef = React.useRef(0);
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setMotion({ ready: true, reduced: media.matches });
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
React.useEffect(() => {
if (!motion.ready || !scripted) return;
const node = stageRef.current;
if (!motion.reduced && node && typeof node.animate === "function") {
node.animate([{ opacity: 0, transform: "translateY(10px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 420, easing: EASE });
}
setEntered(true);
}, [motion.ready, motion.reduced, scripted]);
React.useEffect(() => {
if (!scripted || stopped) return;
const start = performance.now();
const timers: number[] = [];
const click = (place: "overlay" | "toolbar") => {
const button = rootRef.current?.querySelector(`[data-create][data-place="${place}"]`);
if (button instanceof HTMLButtonElement && !button.disabled) button.click();
};
const run = (cycle: number) => {
const base = cycle * CYCLE;
const at = (offset: number, callback: () => void) => {
timers.push(window.setTimeout(callback, Math.max(0, base + offset - (performance.now() - start))));
};
at(FIRST_CLICK, () => click("overlay"));
if (cycle % 2 === 1) at(RETRY_CLICK, () => click("overlay"));
at(SECOND_CLICK, () => click("toolbar"));
at(CYCLE, () => {
cycleRef.current = cycle + 1;
setCycle(cycle + 1);
attemptsInCycle.current = 0;
createdInCycle.current = 0;
if (uncontrolled) setGeneration((current) => current + 1);
else setItems([]);
run(cycle + 1);
});
};
run(0);
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [scripted, stopped, uncontrolled]);
const stop = () => {
if (scripted && !stopped) setStopped(true);
};
const onCreate = async () => {
attemptCount.current += 1;
attemptsInCycle.current += 1;
setAttempts(attemptCount.current);
await wait(delay);
const failNow = forceFail ? attemptCount.current === 1 : scripted && !stopped && cycleRef.current % 2 === 1 && attemptsInCycle.current === 1;
if (failNow) {
setFailures((current) => current + 1);
throw new Error("Could not reach the server");
}
const entry = CATALOG[createdInCycle.current % CATALOG.length] ?? CATALOG[0];
created.current += 1;
createdInCycle.current += 1;
if (!entry) throw new Error("Catalog is empty");
const item: FirstItem = { id: `${entry.slug}-${created.current}`, title: entry.title, meta: "Created just now · You", status: entry.status };
return item;
};
const onItemsChange = (next: FirstItem[]) => {
setChanges((current) => current + 1);
if (!uncontrolled && !lock) setItems(next);
};
return (
<main
data-demo-root
data-stopped={stopped ? "true" : "false"}
data-cycle={cycle}
data-attempts={attempts}
data-changes={changes}
data-imports={imports}
data-failures={failures}
data-selected={selected}
data-mode={uncontrolled ? "uncontrolled" : lock ? "locked" : "controlled"}
onPointerDownCapture={stop}
onKeyDownCapture={stop}
className="flex w-full items-center justify-center overflow-hidden bg-background px-5 text-foreground"
style={{ height: "min(100svh, 760px)" }}
>
<div ref={stageRef} data-stage className="h-[312px] w-full max-w-[640px]" style={{ opacity: entered ? 1 : 0 }}>
<FirstItemEmptyState
key={generation}
ref={rootRef}
title="Projects"
noun="project"
copy={{ title: "No projects yet", description: "Create your first project to start tracking work.", createLabel: "Create project" }}
onCreate={onCreate}
onImport={() => setImports((current) => current + 1)}
onItemSelect={(item) => setSelected(item.id)}
onItemsChange={onItemsChange}
{...(uncontrolled ? { defaultItems: preset } : { items })}
/>
</div>
</main>
);
}
















