Empty state Nothing to show, and the reason decides the copy. No data yet, no data matching the filter, and query failed are three different situations, and a single friendly illustration for all three is how readers learn to distrust the page. npx shadcn@latest add button card Tokens this needs: --card, --card-foreground, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // EmptyPanel.tsx ──────────────────────────────────────────────────────────────────────── import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; /** * Three kinds of nothing, as a closed union. A careless product renders them * identically, and they mean opposite things: one says the system is healthy, * one says you can't tell, one says nobody has wired this up yet. Each kind * carries the one thing its copy needs, so the panel cannot say "No data" * without saying which. */ export type Empty = /** Never received a sample. The next action is setup, not a filter. */ | { kind: "never"; onConnect: () => void } /** Configured, but the query matched nothing. At least one filter, because the copy names them. */ | { kind: "filtered"; filters: [string, ...string[]]; onClear: () => void } /** A real zero. It needs its denominator: zero out of two million is good news, zero out of zero is the first kind. */ | { kind: "zero"; window: string; outOf: string }; const WORDS = ["no", "one", "two", "three", "four", "five"]; const count = (n: number, noun: string) => `${WORDS[n] ?? n} ${noun}${n === 1 ? "" : "s"}`; export function EmptyPanel({ title, state }: { title: string; state: Empty }) { return (

{title}

{state.kind === "never" && ( <>

Nothing here yet

This panel has never received a sample.

)} {state.kind === "filtered" && ( <>

No data

The query returned nothing for {state.filters.join(", ")}.

)} {state.kind === "zero" && ( <>

0

in the {state.window}, from {state.outOf}

)}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { EmptyPanel, type Empty } from "./EmptyPanel"; /** * The same panel three times, once per kind of empty. Clearing the two * filters on the middle one turns it into the real zero on the right, which * is the whole distinction: nothing matched, against nothing happened. */ export default function Demo() { const [middle, setMiddle] = useState({ kind: "filtered", filters: ["region = eu-west", "client = ios"], onClear: () => setMiddle({ kind: "zero", window: "last hour", outOf: "2.1M requests" }), }); return (
console.log("open the source picker") }} />
); }