# Tessli library item — 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.

Kind: component
Category: empty-states
Status: community (not yet reviewed by a maintainer)
Tessli page: /library/empty-state
Preview: /preview/empty-state

## Install

```sh
npx shadcn@latest add online-scope-lab/tessli/empty-state
```

## 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.

## Rules

- H-02 — One h1 per page; nest h2–h4 in order. Level follows structure, size follows style. (/rules/H-02)
- S-02 — Align a section's heading, text and actions on one left edge; centre only short blocks. (/rules/S-02)
- C-02 — Pair every colour-coded meaning (error, selected, status) with text or shape. (/rules/C-02)
- W-02 — Label actions with what happens next, not "Get started", "Submit" or "Learn more". (/rules/W-02)
- ST-01 — Design empty, loading and error states for every region that loads or lists data. (/rules/ST-01)
- ST-02 — Give first use, no results and failure their own message and action. (/rules/ST-02)
- ST-04 — Say what failed, what is still safe, and one way forward (retry, edit or contact). (/rules/ST-04)
- A-01 — Make every interactive target at least 44×44 CSS px; inline text links excepted. (/rules/A-01)

## Common AI 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.

## Why this is better

- 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.

## Common failures when adapting it

- 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

- first-use
- no-results
- error

## Responsive behaviour

- 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.

## Notes

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.

## Credits

- Contributors: none credited yet
- Provenance: original
- Licence: MIT

## Source

`web/registry/items/empty-state/empty-state.tsx`

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

## Interpretation boundaries

- Community items are merged and pass automated checks (schema, build, accessibility, overflow and console errors in three themes at five widths); only items with status reviewed have been checked by a maintainer.
- Each rule id links to its page at /rules/<id>; every rule is a draft until a maintainer signs it.
- Tessli does not claim that using an item improves AI-generated UI; that claim waits for a published evaluation.
