DHxWhy

First Item Empty State – Empty List that Turns into Your First Row

Dashboards

About

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>
  );
}

Dependencies

More by DHxWhy

View all
  • Product Rail Gallery – Fashion Store Hero Section

    Product Rail Gallery – Fashion Store Hero Section

    2.2K
  • Sushi Conveyor Belt Menu – Restaurant Hero Section

    Sushi Conveyor Belt Menu – Restaurant Hero Section

    883
  • Team Lanyard Rail – Meet the Team Section with Flip Badges

    Team Lanyard Rail – Meet the Team Section with Flip Badges

    827
  • Skateboard Deck Rail Gallery – Skate Shop Hero Section

    Skateboard Deck Rail Gallery – Skate Shop Hero Section

    430
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    328
  • Picture Rail Image Gallery – Art Print Shop Hero Section

    Picture Rail Image Gallery – Art Print Shop Hero Section

    268
  • Pendant Lamp Rail Gallery – Lighting Store Hero Section

    Pendant Lamp Rail Gallery – Lighting Store Hero Section

    243
  • World Testimonials Globe – Social Proof Hero Section

    World Testimonials Globe – Social Proof Hero Section

    217

More in Dashboards

View all
  • Dashboard with Collapsible Sidebar

    Dashboard with Collapsible Sidebar

    2.5K
  • Sidebar

    Sidebar

    341
  • SaaS Template

    SaaS Template

    2.6K
  • Course Design Cards

    Course Design Cards

    1.2K
  • Stats Bento

    Stats Bento

    1.2K
  • Chat template

    Chat template

    639
  • Stats cards with links

    Stats cards with links

    128
  • Card

    Card

    231