# Empty State

> A first screen that gives people somewhere to start: worked examples, the first three things to do, or a ghost of the filled screen with sample data.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/empty-state).

- Page, with a live preview: https://shadcn.exchange/r/miseui/empty-state
- Screenshot: https://shadcn.exchange/shot/miseui/empty-state/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/empty-state.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/empty-state.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/motion.json`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/checkbox.json`, `https://miseui.junsu4621.workers.dev/r/progress.json`, `https://miseui.junsu4621.workers.dev/r/status.json`

## Files

### `registry/miseui/blocks/empty-state.tsx`

```tsx
import type * as React from "react"
import { ArrowRight, FileText, ListChecks, Mic } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { EmptyStateChecklist, type EmptyStateChecklistProps, type EmptyStateTask } from "./empty-state-checklist"
import { EmptyStateSample, type EmptyStateSampleProps, type EmptyStateSampleRow } from "./empty-state-sample"

type EmptyStateVariant = "centered" | "checklist" | "sample"

interface EmptyStateStarter {
  id: string
  title: string
  description: string
  icon: React.ComponentType<{ className?: string }>
}

const DEFAULT_STARTERS: readonly EmptyStateStarter[] = [
  { id: "summary", title: "Summarize a document", description: "Drop in a PDF and get the five things that matter.", icon: FileText },
  { id: "plan", title: "Turn notes into a plan", description: "Paste rough notes. Leave with owners and dates.", icon: ListChecks },
  { id: "meeting", title: "Record a meeting", description: "Talk for an hour, read the two-minute version.", icon: Mic },
]

interface EmptyStateCenteredProps extends React.ComponentProps<"section"> {
  title?: string
  description?: string
  actionLabel?: string
  onAction?: () => void
  /** Starting points, so nobody has to invent the first prompt. Pass an empty list to hide them. */
  starters?: readonly EmptyStateStarter[]
  onStarter?: (id: string) => void
}

function EmptyStateCentered({
  title = "Your first project starts here",
  description = "Start from an example or from nothing. You can change everything later.",
  actionLabel = "New project",
  onAction,
  starters = DEFAULT_STARTERS,
  onStarter,
  className,
  ...props
}: EmptyStateCenteredProps) {
  return (
    <section data-slot="empty-state" data-variant="centered" className={cn("@container w-full px-6 py-14", className)} {...props}>
      <div className="mx-auto flex max-w-2xl flex-col items-start gap-8 @2xl:items-center">
        <div className="flex flex-col gap-3 @2xl:items-center @2xl:text-center">
          <h2 className="text-display text-3xl text-balance @2xl:text-4xl">{title}</h2>
          <p className="max-w-md text-[15px] leading-relaxed text-pretty text-muted-foreground">{description}</p>
          <Button variant="mise" size="lg" onClick={onAction} className="mt-2">
            {actionLabel} <ArrowRight />
          </Button>
        </div>
        {starters.length > 0 ? (
          <ul className="flex w-full flex-col divide-y rounded-2xl border bg-card">
            {starters.map((starter) => (
              <li key={starter.id}>
                <button
                  type="button"
                  onClick={() => onStarter?.(starter.id)}
                  className="group flex w-full items-center gap-4 rounded-2xl px-4 py-3.5 text-left outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent/60 focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50"
                >
                  <span className="grid size-9 shrink-0 place-items-center rounded-xl bg-muted text-muted-foreground transition-colors duration-(--duration-fast) ease-(--ease-mise) group-hover:bg-mise-soft group-hover:text-foreground">
                    <starter.icon className="size-4" />
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block text-sm font-medium">{starter.title}</span>
                    <span className="block text-sm text-pretty text-muted-foreground">{starter.description}</span>
                  </span>
                  <ArrowRight className="size-4 shrink-0 text-muted-foreground opacity-0 transition-[opacity,translate] duration-(--duration-fast) ease-(--ease-mise) group-hover:translate-x-0.5 group-hover:opacity-100" />
                </button>
              </li>
            ))}
          </ul>
        ) : null}
      </div>
    </section>
  )
}

type EmptyStateVariantProps = Pick<EmptyStateChecklistProps, "tasks" | "completed" | "defaultCompleted" | "onCompletedChange" | "onTask"> &
  Pick<EmptyStateSampleProps, "sampleRows" | "sampleLabel" | "onUseSample" | "onClearSample">

interface EmptyStateProps extends EmptyStateCenteredProps, EmptyStateVariantProps {
  /** `centered` gives examples to start from, `checklist` the first things to do, `sample` a ghost of the filled screen. Anything else is `centered`. */
  variant?: EmptyStateVariant
}

function EmptyState({
  variant,
  starters,
  onStarter,
  tasks,
  completed,
  defaultCompleted,
  onCompletedChange,
  onTask,
  sampleRows,
  sampleLabel,
  onUseSample,
  onClearSample,
  ...shared
}: EmptyStateProps) {
  if (variant === "checklist") {
    return <EmptyStateChecklist {...shared} tasks={tasks} completed={completed} defaultCompleted={defaultCompleted} onCompletedChange={onCompletedChange} onTask={onTask} />
  }
  if (variant === "sample") {
    return <EmptyStateSample {...shared} sampleRows={sampleRows} sampleLabel={sampleLabel} onUseSample={onUseSample} onClearSample={onClearSample} />
  }
  return <EmptyStateCentered {...shared} starters={starters} onStarter={onStarter} />
}

export { EmptyState, type EmptyStateProps, type EmptyStateSampleRow, type EmptyStateStarter, type EmptyStateTask, type EmptyStateVariant }
```

### `registry/miseui/blocks/empty-state-checklist.tsx`

```tsx
import type * as React from "react"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Checkbox } from "@/registry/miseui/ui/checkbox"
import { Progress } from "@/registry/miseui/ui/progress"

interface EmptyStateTask {
  id: string
  title: string
  description: string
  actionLabel: string
}

const DEFAULT_TASKS: readonly EmptyStateTask[] = [
  { id: "project", title: "Create your first project", description: "Give it a name. You can change it later.", actionLabel: "New project" },
  { id: "document", title: "Add a document", description: "Drop in a PDF and get the five things that matter.", actionLabel: "Upload a PDF" },
  { id: "invite", title: "Invite one person", description: "Work is easier to finish with someone reading along.", actionLabel: "Invite" },
]

const FOCUS_NEXT_SELECTOR = "[data-focus-next]"

/** Tinted from the ink, and off in dark mode, where a light-tinted shadow would read as a glow. */
const PLATE_CLASS =
  "rounded-2xl border bg-background dark:bg-card shadow-[0_1px_2px_color-mix(in_oklab,var(--foreground)_6%,transparent),0_16px_32px_-16px_color-mix(in_oklab,var(--foreground)_16%,transparent)] dark:shadow-none"

interface EmptyStateChecklistProps extends React.ComponentProps<"section"> {
  title?: string
  /** Continues the title in the same size, one tone quieter. */
  description?: string
  /** The button shown once every task is done. */
  actionLabel?: string
  onAction?: () => void
  /** The first things to do. Three is the point: more than that reads as homework. */
  tasks?: readonly EmptyStateTask[]
  /** Ids of finished tasks. Pass it to decide yourself when a task counts as done; leave it out and pressing a task's button ticks it. */
  completed?: readonly string[]
  defaultCompleted?: readonly string[]
  onCompletedChange?: (completed: readonly string[]) => void
  /** Fires with the task whose button was pressed. Open your dialog or route here. */
  onTask?: (id: string) => void
}

function useCompleted(
  completed: readonly string[] | undefined,
  defaultCompleted: readonly string[],
  onChange?: (completed: readonly string[]) => void,
) {
  const [internal, setInternal] = useState(defaultCompleted)
  const current = completed ?? internal
  const set = (id: string, done: boolean) => {
    const next = done ? [...current.filter((item) => item !== id), id] : current.filter((item) => item !== id)
    setInternal(next)
    onChange?.(next)
  }
  return [current, set, completed !== undefined] as const
}

interface TaskRowProps {
  task: EmptyStateTask
  done: boolean
  next: boolean
  onToggle: (done: boolean) => void
  onAction: () => void
}

function TaskRow({ task, done, next, onToggle, onAction }: TaskRowProps) {
  return (
    <li data-state={done ? "done" : next ? "next" : "later"} className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-3 p-4 @md:grid-cols-[auto_minmax(0,1fr)_auto] @md:items-center @2xl:px-6">
      {/* The label is the hit target: a 16 px box stays 16 px while the press area reaches 40. */}
      <label className="-mx-2 -my-2 inline-flex size-10 cursor-pointer items-center justify-center self-start">
        <Checkbox aria-label={task.title} checked={done} onCheckedChange={(state) => onToggle(state === true)} />
      </label>
      <div className="min-w-0">
        <p className={cn("text-[15px] leading-normal text-pretty", done && "text-muted-foreground line-through")}>{task.title}</p>
        {done ? null : <p className="mt-1 text-[13px] leading-relaxed text-pretty text-muted-foreground">{task.description}</p>}
      </div>
      {done ? null : (
        <Button
          variant={next ? "mise" : "ghost"}
          data-focus-next={next ? "" : undefined}
          onClick={onAction}
          className="col-start-2 justify-self-start pointer-coarse:h-10 @md:col-start-3 @md:row-start-1"
        >
          {task.actionLabel}
        </Button>
      )}
    </li>
  )
}

/** A pressed task button disappears with its row, which would drop keyboard focus onto the page. */
function useFocusAfterAction(plate: React.RefObject<HTMLDivElement | null>, doneCount: number) {
  const pendingRef = useRef(false)
  useEffect(() => {
    if (!pendingRef.current) return
    pendingRef.current = false
    plate.current?.querySelector<HTMLElement>(FOCUS_NEXT_SELECTOR)?.focus()
  }, [doneCount, plate])
  return pendingRef
}

interface ChecklistPlateProps {
  tasks: readonly EmptyStateTask[]
  done: readonly string[]
  finish: React.ReactNode
  onToggle: (id: string, done: boolean) => void
  onPress: (id: string) => void
  plateRef: React.RefObject<HTMLDivElement | null>
}

function ChecklistPlate({ tasks, done, finish, onToggle, onPress, plateRef }: ChecklistPlateProps) {
  const doneCount = tasks.filter((task) => done.includes(task.id)).length
  const nextId = tasks.find((task) => !done.includes(task.id))?.id
  return (
    <div ref={plateRef} className={PLATE_CLASS}>
      <div className="flex flex-col gap-3 border-b p-4 @2xl:px-6">
        <p role="status" className="text-[13px] text-muted-foreground tabular-nums">
          {doneCount} of {tasks.length} done
        </p>
        {/* In ink: the one accent on this screen belongs to the next thing to press. */}
        <Progress
          variant="segmented"
          effect="spring"
          label="Setup progress"
          value={doneCount}
          max={tasks.length}
          steps={tasks.length}
          className="[&_[data-slot=progress-segment]>span]:bg-foreground"
        />
      </div>
      <ol className="flex flex-col divide-y">
        {tasks.map((task) => (
          <TaskRow
            key={task.id}
            task={task}
            done={done.includes(task.id)}
            next={task.id === nextId}
            onToggle={(state) => onToggle(task.id, state)}
            onAction={() => onPress(task.id)}
          />
        ))}
      </ol>
      {doneCount === tasks.length ? (
        <div className="flex flex-wrap items-center justify-between gap-3 border-t p-4 @2xl:px-6">
          <p className="text-[15px]">That is everything.</p>
          {finish}
        </div>
      ) : null}
    </div>
  )
}

function EmptyStateChecklist({
  title = "Three things to do first.",
  description = "About five minutes, and you can skip around.",
  actionLabel = "Open your project",
  onAction,
  tasks = DEFAULT_TASKS,
  completed,
  defaultCompleted = [],
  onCompletedChange,
  onTask,
  className,
  ...props
}: EmptyStateChecklistProps) {
  const [done, setDone, controlled] = useCompleted(completed, defaultCompleted, onCompletedChange)
  const plateRef = useRef<HTMLDivElement>(null)
  const pendingFocusRef = useFocusAfterAction(plateRef, done.length)

  const pressTask = (id: string) => {
    onTask?.(id)
    if (controlled) return
    pendingFocusRef.current = true
    setDone(id, true)
  }
  const finish = (
    <Button variant="mise" data-focus-next="" onClick={onAction} className="pointer-coarse:h-10">
      {actionLabel}
    </Button>
  )

  return (
    <section data-slot="empty-state" data-variant="checklist" className={cn("@container w-full bg-muted dark:bg-background", className)} {...props}>
      <div className="px-4 py-12 @2xl:px-8 @2xl:py-20">
        <div className="mx-auto flex max-w-2xl flex-col gap-8">
          <h2 className="text-display text-3xl text-balance @2xl:text-4xl">
            {title} <span className="text-muted-foreground">{description}</span>
          </h2>
          {tasks.length === 0 ? <div>{finish}</div> : <ChecklistPlate tasks={tasks} done={done} finish={finish} onToggle={setDone} onPress={pressTask} plateRef={plateRef} />}
        </div>
      </div>
    </section>
  )
}

export { EmptyStateChecklist, type EmptyStateChecklistProps, type EmptyStateTask }
```

### `registry/miseui/blocks/empty-state-sample.tsx`

```tsx
import type * as React from "react"
import { useEffect, useRef, useState } from "react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Status, type StatusTone } from "@/registry/miseui/ui/status"

interface EmptyStateSampleRow {
  id: string
  title: string
  detail: string
  status: string
  tone?: StatusTone
  when: string
}

/** Invented rows, shown only after someone asks for them, and labelled as made up on the screen. */
const DEFAULT_ROWS: readonly EmptyStateSampleRow[] = [
  { id: "board", title: "Q3 board summary", detail: "Five things from a 42 page PDF", status: "In review", tone: "warning", when: "2 hours ago" },
  { id: "plan", title: "Onboarding plan", detail: "Rough notes with owners and dates", status: "Draft", tone: "neutral", when: "Yesterday" },
  { id: "sync", title: "Weekly sync", detail: "The two-minute version", status: "Done", tone: "success", when: "Oct 28" },
  { id: "pricing", title: "Pricing research", detail: "Eleven sources, one page", status: "Draft", tone: "neutral", when: "Oct 24" },
]

/** Different on every row, because equal bars read as a loading skeleton and not as a screen waiting to be filled. */
const GHOST_WIDTHS = [
  ["w-2/5", "w-3/5"],
  ["w-1/3", "w-1/2"],
  ["w-1/2", "w-2/5"],
  ["w-1/4", "w-3/5"],
  ["w-2/5", "w-1/3"],
  ["w-1/3", "w-1/2"],
] as const

const ROW_CLASS = "grid min-h-16 grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-3 @2xl:grid-cols-[minmax(0,1fr)_112px_96px] @2xl:px-6"
const BAR_CLASS = "block h-2 rounded-full bg-foreground/10 dark:bg-foreground/15"

type SamplePhase = "ghost" | "loading" | "failed" | "loaded"

interface EmptyStateSampleProps extends React.ComponentProps<"section"> {
  title?: string
  /** Continues the title in the same size, one tone quieter. */
  description?: string
  /** The way past the sample: starting for real. */
  actionLabel?: string
  onAction?: () => void
  /** What the ghost turns into. The ghost has as many rows, so nothing jumps. */
  sampleRows?: readonly EmptyStateSampleRow[]
  sampleLabel?: string
  /** Load the sample here. Return a promise to show progress and a retry if it rejects. */
  onUseSample?: () => void | Promise<void>
  onClearSample?: () => void
}

/** Bars and no words: real-looking figures in an empty product would read as someone else's data. */
function SampleGhost({ rows }: { rows: number }) {
  return (
    <div data-testid="empty-state-ghost" aria-hidden="true" className="rounded-2xl border border-dashed bg-background/60 [mask-image:linear-gradient(to_bottom,black_40%,transparent)] dark:bg-card/40">
      {GHOST_WIDTHS.slice(0, rows).map(([first, second], index) => (
        <div key={index} className={cn(ROW_CLASS, index > 0 && "border-t border-dashed")}>
          <div className="flex min-w-0 flex-col gap-2">
            <span className={cn(BAR_CLASS, first)} />
            <span className={cn(BAR_CLASS, second, "opacity-60")} />
          </div>
          <span className={cn(BAR_CLASS, "w-16")} />
          <span className={cn(BAR_CLASS, "hidden w-12 justify-self-end @2xl:block")} />
        </div>
      ))}
    </div>
  )
}

function SampleList({ rows }: { rows: readonly EmptyStateSampleRow[] }) {
  const reduceMotion = useReducedMotion()
  return (
    <motion.ul
      aria-label="Sample projects"
      initial={reduceMotion ? false : { opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={SPRING_DEFAULT}
      className="flex flex-col divide-y rounded-2xl border bg-background shadow-[0_1px_2px_color-mix(in_oklab,var(--foreground)_6%,transparent),0_16px_32px_-16px_color-mix(in_oklab,var(--foreground)_16%,transparent)] dark:bg-card dark:shadow-none"
    >
      {rows.map((row) => (
        <li key={row.id} className={ROW_CLASS}>
          <div className="min-w-0">
            <p className="text-[15px] leading-normal text-pretty">{row.title}</p>
            <p className="text-[13px] leading-relaxed text-pretty text-muted-foreground">{row.detail}</p>
          </div>
          <Status state="custom" tone={row.tone ?? "neutral"} className="text-[13px]">
            {row.status}
          </Status>
          <p className="hidden text-right text-[13px] text-muted-foreground @2xl:block">{row.when}</p>
        </li>
      ))}
    </motion.ul>
  )
}

function useSamplePhase(onUseSample: EmptyStateSampleProps["onUseSample"], onClearSample: EmptyStateSampleProps["onClearSample"]) {
  const [phase, setPhase] = useState<SamplePhase>("ghost")
  const load = async () => {
    setPhase("loading")
    try {
      await onUseSample?.()
      setPhase("loaded")
    } catch {
      // The ghost stays, so a failed load leaves the screen exactly as it was.
      setPhase("failed")
    }
  }
  const clear = () => {
    setPhase("ghost")
    onClearSample?.()
  }
  return { phase, load, clear }
}

/** Each of these buttons replaces the other, and focus must not fall back to the top of the page with it. */
function useFocusAcrossPhases(phase: SamplePhase) {
  const sampleRef = useRef<HTMLButtonElement>(null)
  const clearRef = useRef<HTMLButtonElement>(null)
  const previousRef = useRef(phase)
  useEffect(() => {
    const previous = previousRef.current
    previousRef.current = phase
    if (phase === "loaded") clearRef.current?.focus()
    if (phase === "ghost" && previous === "loaded") sampleRef.current?.focus()
  }, [phase])
  return { sampleRef, clearRef }
}

interface SampleActionsProps {
  phase: SamplePhase
  sampleLabel: string
  actionLabel: string
  onAction?: () => void
  onLoad: () => void
  onClear: () => void
}

function SampleActions({ phase, sampleLabel, actionLabel, onAction, onLoad, onClear }: SampleActionsProps) {
  const { sampleRef, clearRef } = useFocusAcrossPhases(phase)
  const loading = phase === "loading"
  if (phase === "loaded") {
    return (
      <div className="flex flex-wrap items-center gap-3">
        <Button variant="mise" size="lg" onClick={onAction}>{actionLabel}</Button>
        <Button ref={clearRef} variant="outline" size="lg" onClick={onClear}>Clear sample data</Button>
      </div>
    )
  }
  return (
    <div className="flex flex-wrap items-center gap-3">
      {/* Not disabled: a disabled button drops keyboard focus, and it would be lost for the whole load. */}
      <Button ref={sampleRef} variant="mise" size="lg" effect={loading ? "shimmer" : undefined} aria-disabled={loading || undefined} onClick={loading ? undefined : onLoad}>
        {phase === "failed" ? "Try again" : loading ? "Loading sample" : sampleLabel}
      </Button>
      <Button variant="outline" size="lg" onClick={onAction}>{actionLabel}</Button>
    </div>
  )
}

function EmptyStateSample({
  title = "This is where your projects will live.",
  description = "See it filled in first, then clear it when you are ready.",
  actionLabel = "New project",
  onAction,
  sampleRows = DEFAULT_ROWS,
  sampleLabel = "Use sample data",
  onUseSample,
  onClearSample,
  className,
  ...props
}: EmptyStateSampleProps) {
  const { phase, load, clear } = useSamplePhase(onUseSample, onClearSample)
  const loaded = phase === "loaded"

  return (
    <section data-slot="empty-state" data-variant="sample" data-phase={phase} className={cn("@container w-full bg-muted dark:bg-background", className)} {...props}>
      <div className="px-4 py-12 @2xl:px-8 @2xl:py-20">
        <div className="mx-auto flex max-w-3xl flex-col gap-8">
          <div className="flex flex-col gap-6">
            <h2 className="text-display text-3xl text-balance @2xl:text-4xl">
              {loaded ? "Sample data is loaded." : title} <span className="text-muted-foreground">{loaded ? "Everything here is made up, and nothing is saved." : description}</span>
            </h2>
            <SampleActions phase={phase} sampleLabel={sampleLabel} actionLabel={actionLabel} onAction={onAction} onLoad={load} onClear={clear} />
            {phase === "failed" ? <p role="alert" className="text-[13px] text-destructive">The sample did not load. Nothing on this screen changed.</p> : null}
          </div>
          {loaded ? <SampleList rows={sampleRows} /> : <SampleGhost rows={sampleRows.length || GHOST_WIDTHS.length} />}
        </div>
      </div>
    </section>
  )
}

export { EmptyStateSample, type EmptyStateSampleProps, type EmptyStateSampleRow }
```
