Empty state that says why and what next
Replaces a list, table or feed that has nothing to show, and tells people why it is empty and the one thing to do next.
Community — not yet reviewed by a maintainer
Preview
1440 px wide. Open this preview on its own ↗
Install
npx shadcn@latest add online-scope-lab/tessli/empty-stateRun it in a project set up with shadcn/ui. It adds empty-state.tsx to your components folder, and the item takes on your project’s theme.
When to use it
Use when
- A region that normally holds content is empty on first use, after a search or filter, or because loading failed.
- There is one clear next action, such as creating, importing, clearing a filter or retrying.
Avoid when
- The whole screen is empty because the user has no access; show a permission message with who to ask instead.
- Content is still loading; show a skeleton or progress state, not an empty state.
- The empty region is minor (a sidebar list, a small widget); a single muted sentence is enough.
Common AI default vs. this
The common default
A large centred illustration or icon, the headline "Nothing here yet", a vague line such as "Get started by creating your first item", and the same generic message for first use, no search results and failed loads.
What this item does instead
- The title names what is empty in the user's words ("No notes in Field research yet"), so it reads correctly out of context and to screen readers.
- First use, no results and error are different states with different copy and actions, because they have different causes.
- No results echoes the query and says what the search covered, so people can tell a typo from missing content.
- The error state says what failed and what is safe ("Nothing was deleted") before offering a retry, and is labelled in text, not only by colour.
- It sits where the content would be, left-aligned with the page, instead of a centred illustration that pushes the action below the fold.
- One primary action, at most one secondary, both at least 44px tall.
Why it is built this way
An empty state is the first screen many people see, and the screen they see when something goes wrong. The common default treats all of those moments the same: an illustration and "Nothing here yet". This item treats them as three different situations.
First use explains what will appear and offers the action that fills it. No results repeats the query and the search scope, then offers a wider search or a reset. Error says what failed and what is safe, then offers a retry. Loading is out of scope: pair this item with a skeleton or progress state to cover all of ST-01.
The component is deliberately plain: a heading, a sentence or two, and actions. Put it where the content would have been, at the same alignment, so the page does not jump when content arrives.
Rules
- H-02
- S-02
- C-02
- W-02
- ST-01
- ST-02
- ST-04
- A-01
Each rule page says why the rule matters and how to check it. Rules are drafts until a maintainer signs them; see all rules.
How it breaks
Common mistakes when adapting this item.
- Adding an illustration that is larger than the message and pushes the action off small screens.
- Repeating a primary action that the toolbar above already offers, so two buttons compete for the same job.
- Using one generic "No data" message for every cause.
- Turning the error state into a red banner with no explanation of what is safe or what to do.
States, responsive behaviour and accessibility
- States
- first use, no results, error
- Desktop
- Bordered region aligned with the surrounding content; actions sit side by side under the text, which keeps a readable measure.
- Mobile
- Same order; padding tightens and actions become full-width buttons stacked in priority order.
Accessibility
- A real heading (level set by headingLevel to match the page outline) names the region through aria-labelledby.
- The error variant adds a text label above the title, so the state is not conveyed by colour alone.
- Actions are native buttons or links at least 44px tall with a visible focus ring.
- If the state appears after an action (a search, a failed save), announce it from your own live region; the component does not announce on its own.
Code
web/registry/items/empty-state/empty-state.tsx
import * as React from "react";
import { cn } from "@/registry/shadcn/lib/utils";
type HeadingLevel = 2 | 3 | 4;
export type EmptyStateProps = Omit<React.ComponentProps<"section">, "title"> & {
/** Says what is empty, in the user's words: "No notes in Field research yet". */
title: React.ReactNode;
/** One or two sentences: why it is empty and what will appear here. */
description?: React.ReactNode;
/**
* `neutral` for first use and no results; `error` when something failed.
* Error adds a text label, so the state never relies on colour alone.
*/
variant?: "neutral" | "error";
/** Text shown before the title in the error variant. */
errorLabel?: string;
/** Match the surrounding outline; the empty state replaces a region's content. */
headingLevel?: HeadingLevel;
/** Actions: one primary, at most one secondary. */
actions?: React.ReactNode;
};
export function EmptyState({
title,
description,
variant = "neutral",
errorLabel = "Error",
headingLevel = 2,
actions,
children,
className,
...props
}: EmptyStateProps) {
const headingId = React.useId();
const Heading = `h${headingLevel}` as const;
const isError = variant === "error";
return (
<section
aria-labelledby={headingId}
data-slot="empty-state"
data-variant={variant}
className={cn(
"rounded-lg border bg-card px-5 py-6 text-card-foreground sm:px-8 sm:py-8",
isError ? "border-destructive/50" : "border-dashed border-border",
className,
)}
{...props}
>
<div className="max-w-prose">
<Heading
id={headingId}
className="text-lg leading-snug font-semibold tracking-tight text-balance"
>
{isError ? (
<span className="mb-1 block text-sm font-semibold text-destructive">
{errorLabel}
</span>
) : null}
{title}
</Heading>
{description ? (
<div className="mt-2 text-sm leading-relaxed text-pretty text-muted-foreground">
{description}
</div>
) : null}
{children ? <div className="mt-4 text-sm">{children}</div> : null}
</div>
{actions ? (
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
{actions}
</div>
) : null}
</section>
);
}
Credits and licence
- Contributors
- None credited yet.
- Provenance
- Original work
- Licence
- MIT
- Review
- Community — not yet reviewed by a maintainer
For agents
The same taste notes and code as structured data.