EmptyState

What a list, table or search shows when it has nothing: an icon, a title, a sentence of explanation and the next step. It centers its parts in a column and takes its padding, gap, type and colors from the emptystate contract tokens. The padding and gap follow density. It draws no border or background, so it sits in a page, a card or a table cell as it is.

On this page
import { FolderPlus } from 'lucide-react';
import { Button, EmptyState } from '@tounsoo/todae';

<EmptyState.Root>
  <EmptyState.Icon>
    <FolderPlus />
  </EmptyState.Icon>
  <EmptyState.Title>No projects yet</EmptyState.Title>
  <EmptyState.Description>
    Projects group your files and teammates. Create one to get started.
  </EmptyState.Description>
  <EmptyState.Actions>
    <Button.Root>New project</Button.Root>
    <Button.Root variant="secondary">Import a project</Button.Root>
  </EmptyState.Actions>
</EmptyState.Root>

No projects yet

Projects group your files and teammates. Create one to get started.

Todae ships no icons. EmptyState.Icon is a slot: put any icon from your own set in it. An svg or img directly inside takes the emptystate.icon.size token, and the icon is hidden from assistive technology. There is no illustration part; an icon slot is all it has. EmptyState.Title and EmptyState.Description stay within the emptystate.max-width width, so a long description keeps a readable line length. EmptyState.Actions is a centered row that wraps.

Server Components can import it from @tounsoo/todae/empty-state. That entry has no 'use client' and no hooks; the main @tounsoo/todae entry is a client module.

No results

When a search or filter finds nothing, show the empty state in place of the results and say what was searched. The example below also announces the result with useAnnouncer: the empty state is a div with no role, so Todae does not announce it when it appears. It announces once the result has settled, not on every keystroke, and "Clear search" moves focus back to the input before its own button goes away.

import { useEffect, useId, useRef, useState } from 'react';
import { SearchX } from 'lucide-react';
import { Button, EmptyState, Field, Label, useAnnouncer } from '@tounsoo/todae';

const PROJECTS = ['Atlas', 'Beacon', 'Cedar', 'Delta'];

function search(query: string) {
  return PROJECTS.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()));
}

export function ProjectSearch() {
  const id = useId();
  const input = useRef<HTMLInputElement>(null);
  const announce = useAnnouncer();
  const [query, setQuery] = useState('');
  const matches = search(query);
  const count = matches.length;
  const announced = useRef(count);

  // Announce once the result settles, and only when it changed.
  useEffect(() => {
    const timer = setTimeout(() => {
      if (announced.current === count) return;
      announced.current = count;
      announce(count === 0 ? 'No projects found' : `${count} ${count === 1 ? 'project' : 'projects'} found`);
    }, 500);
    return () => clearTimeout(timer);
  }, [count, announce]);

  return (
    <>
      <Field.Group>
        <Label.Root htmlFor={id}>Search projects</Label.Root>
        <Field.Root id={id}>
          <Field.TextInput ref={input} value={query} onChange={(event) => setQuery(event.target.value)} />
        </Field.Root>
      </Field.Group>
      {count > 0 ? (
        <ul>
          {matches.map((name) => (
            <li key={name}>{name}</li>
          ))}
        </ul>
      ) : (
        <EmptyState.Root>
          <EmptyState.Icon>
            <SearchX />
          </EmptyState.Icon>
          <EmptyState.Title as="h3">No projects found</EmptyState.Title>
          <EmptyState.Description>
            Nothing matches “{query}”. Check the spelling or try a shorter search.
          </EmptyState.Description>
          <EmptyState.Actions>
            <Button.Root
              variant="secondary"
              onClick={() => {
                setQuery('');
                input.current?.focus();
              }}
            >
              Clear search
            </Button.Root>
          </EmptyState.Actions>
        </EmptyState.Root>
      )}
    </>
  );
}
  • Atlas
  • Beacon
  • Cedar
  • Delta

Heading level

EmptyState.Title renders an h2 by default. Its as prop takes h2 to h6, or p when the title should not appear in the page outline. Pick the level that fits where the empty state sits, one below the heading of the section that holds it. A component that shows an empty state in many places should take the level as a prop, as the Card page shows, rather than fix it.

import type { ReactNode } from 'react';
import { EmptyState, type EmptyStateTitleAs } from '@tounsoo/todae';

type EmptyListProps = { title: string; level: EmptyStateTitleAs; children: ReactNode };

export function EmptyList({ title, level, children }: EmptyListProps) {
  return (
    <EmptyState.Root>
      <EmptyState.Title as={level}>{title}</EmptyState.Title>
      <EmptyState.Description>{children}</EmptyState.Description>
    </EmptyState.Root>
  );
}

Team settings

No members yet

Invite people to give them access.

Accessible composition

Todae handles: Layout and paint, a real heading for the title and hiding the icon from assistive technology. Every part is a plain element: EmptyState.Root is a div with no role, and EmptyState.Title is a real heading, an h2 unless as says otherwise. EmptyState.Icon is aria-hidden; pass aria-hidden={false} to expose it.

You supply: The heading level, through as. An icon only as decoration, or with a name: an icon that carries meaning on its own needs aria-hidden={false}, role="img" and an aria-label. When the empty state appears after a user action, such as a search or a filter, announce it with useAnnouncer or a live region your product owns, since Todae does not announce it.

Watch for: An empty state inside a table: put it in a cell that spans all the columns, such as <td colSpan={4}>, or outside the table. As a direct child of table or tbody, a div is invalid. A role="status" on the root: a live region that mounts with its message often is not read, so announce through useAnnouncer instead. An action that ends the empty state, such as "Clear search", removes its own buttons: move focus somewhere that stays, such as the search input, or focus falls to the page. Skipping heading levels, such as an h4 empty state under an h2.

When to use

When: A list, table, search or whole page that has nothing to show, where you can say why and what to do next: a first-run screen, no search results, a cleared inbox.

When not: An error or warning about the page → Alert. Content that is still loading → Loader or Skeleton. A field's own error → ErrorMessage.

Props

EmptyState.Root props
NameTypeDescription
childrenReact.ReactNode

EmptyState parts, or any content.

EmptyState.Icon props
NameTypeDescription
childrenReact.ReactNode

Icon graphic from your own icon set.

EmptyState.Title props
NameTypeDescription
as"h2" | "h3" | "h4" | "h5" | "h6" | "p"

The rendered element. Pick the heading level that fits the page, or p to keep the title out of the outline.

Default: "h2"

childrenReact.ReactNode

Title text.

EmptyState.Description props
NameTypeDescription
childrenReact.ReactNode

Description text, or any content.

EmptyState.Actions props
NameTypeDescription
childrenReact.ReactNode

Buttons or links.