Patterns
PatternsTier 5 · Blocks
Empty & error states
Say what happened and offer the way out.
bunx --bun shadcn@latest add @woohl/empty-statePreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/empty-stateregistry/tier-5-blocks/empty-state.tsx
import type { ReactElement, ReactNode } from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { AlertGlyph } from "@/registry/tier-2-primitives/glyphs";
/**
* EmptyState.
*
* AN EMPTY STATE IS A BRIEF, NOT AN APOLOGY
* "Nothing under ₹500 in Apparel" tells a person what happened and implies
* what to change. "No results found" tells them nothing and leaves them to
* guess whether the site is broken.
*
* So the title states the specific situation, the body offers the way out, and
* the action is the way out made pressable. If you can't write a title with a
* noun from the person's own query in it, the empty state isn't finished.
*
* The icon sits in a quiet tile rather than a big illustration. Woohl is a
* photographic brand — a blob-people illustration for an empty bag is the
* wrong voice, and the imagery rules rule it out explicitly.
*/
export type EmptyStateProps = {
/** State the situation, with specifics. */
title: string;
/** The way out, in one or two sentences. */
body?: string;
/** The tile glyph, from the app's own set: `icon={<Icon name="search" />}`. */
icon?: ReactElement;
/** Usually one Button. Two at most, and the second should be `secondary`. */
action?: ReactNode;
/** `page` for a whole view; `inline` inside a card or a panel. */
variant?: "page" | "inline";
className?: string;
};
export function EmptyState({
title,
body,
icon,
action,
variant = "page",
className,
}: EmptyStateProps) {
return (
<div
data-slot="empty-state"
className={cn(
"font-ui flex flex-col items-center text-center",
variant === "page" ? "px-6 py-12" : "px-4 py-8",
className,
)}
>
{icon && (
<span
aria-hidden
className="bg-surface-sunken text-text-muted grid size-14 place-items-center rounded-xl [&>svg]:size-6"
>
{icon}
</span>
)}
<h3
className={cn(
"text-text-primary mt-5 font-bold",
variant === "page" ? "text-h3" : "text-body-lg",
)}
>
{title}
</h3>
{body && (
<p className="text-body text-text-secondary mt-2 max-w-[42ch] leading-[1.6]">
{body}
</p>
)}
{action && (
<div className="mt-6 flex flex-wrap justify-center gap-2.5">
{action}
</div>
)}
</div>
);
}
/**
* The error state.
*
* Same shape, different job: say what failed, and give a way to try again. The
* retry is the point — an error screen with no action is a dead end, and "an
* error occurred" with a reload button the person has to find themselves is
* the most common version of it.
*/
export function ErrorState({
title = "That didn't load",
body = "Something went wrong on our side. It's usually temporary.",
action,
className,
}: {
title?: string;
body?: string;
action?: ReactNode;
className?: string;
}) {
return (
<div
data-slot="error-state"
role="alert"
className={cn(
"font-ui flex flex-col items-center px-6 py-12 text-center",
className,
)}
>
<span className="bg-danger-subtle text-danger grid size-14 place-items-center rounded-xl">
<AlertGlyph size={24} />
</span>
<h3 className="text-h3 text-text-primary mt-5 font-bold">{title}</h3>
<p className="text-body text-text-secondary mt-2 max-w-[42ch] leading-[1.6]">
{body}
</p>
{action && <div className="mt-6">{action}</div>}
</div>
);
}
EmptyStateProps
| Prop | Type | Default |
|---|---|---|
title*State the situation, with specifics. | string | — |
actionUsually one Button. Two at most, and the second should be `secondary`. | ReactNode | — |
bodyThe way out, in one or two sentences. | string | — |
className | string | — |
iconThe tile glyph, from the app's own set: `icon={<Icon name="search" />}`. | ReactElement | — |
variant`page` for a whole view; `inline` inside a card or a panel. | "page" | "inline" | — |
Registry items
npm packages
None beyond React itself.