Empty states

Show what a region holds when it has nothing to show, and the one way on.

BetaSince 1.0.0
On this page

Import

import {
  Alert,
  Badge,
  Button,
  Column,
  Grid,
  List,
  Page,
  PageHeader,
  placeholders,
  Row,
  Scaffold,
  SearchField,
  Select,
  Surface,
  Table,
  Text,
  TopBar,
} from '@envsemble/envui';

Usage

An empty state is a Column of Text and a Button, with no component of its own:

  • the icon in a muted Text;
  • the heading as Text with variant="title" at the level the outline needs, such as render={<h4 />};
  • the description as Text with variant="body-sm";
  • then the one Button.

Render it in place of the List or the Table when there is nothing to show, and once something exists, show the page's own action instead.

FirstUse.tsx
import { useEffect, useRef, useState } from 'react';
import { Button, Grid, List, Column, Row, Surface, Text } from '@envsemble/envui';
import { IconAdd } from '@envsemble/envui/icons/Add';
import { IconFolderOpen } from '@envsemble/envui/icons/FolderOpen';

export default function FirstUse() {
  const [projects, setProjects] = useState<string[]>([]);
  const list = useRef<HTMLUListElement>(null);
  const leftEmpty = useRef(false);
  // The empty state's button goes with it, so focus moves to the list that replaced it.
  useEffect(() => {
    if (leftEmpty.current) list.current?.focus();
    leftEmpty.current = false;
  }, [projects]);

  const create = () => {
    leftEmpty.current = projects.length === 0;
    // A real application opens the create form here; the example adds a project at once.
    setProjects((current) => [...current, sample[current.length % sample.length]]);
  };

  return (
    <Column gap={4}>
      {/* As tall as its button from the start, so the heading stays put as the button comes. */}
      <Row
        wrap
        align="center"
        justify="between"
        gap={3}
        style={{ minBlockSize: 'var(--env-control-height-sm)' }}
      >
        <Text render={<h3 />} variant="title">
          Projects
        </Text>
        {/* The page's own action appears with the first project; until then the state offers it. */}
        {projects.length > 0 && (
          <Button size="sm" startIcon={<IconAdd />} onClick={create}>
            Create project
          </Button>
        )}
      </Row>
      {projects.length > 0 ? (
        <List.Root ref={list} aria-label="Projects" tabIndex={-1}>
          {projects.map((project, index) => (
            <List.Item key={`${project}-${index}`} label={project} />
          ))}
        </List.Root>
      ) : (
        // envUI has no region height for an example: the state fills the place the list will.
        <Surface
          render={<Grid />}
          background="default"
          padding={0}
          style={{ minBlockSize: '18rem' }}
        >
          {/* An icon, a heading of the level the page's outline needs, a sentence and the one
              action, centred in the room the list will take. */}
          <Column
            gap={4}
            padding={5}
            align="center"
            justify="center"
            style={{ textAlign: 'center' }}
          >
            <Text render={<span />} emphasis="muted">
              <IconFolderOpen width="2.5rem" height="2.5rem" />
            </Text>
            <Column gap={2} align="center" style={{ maxInlineSize: '30rem' }}>
              <Text render={<h4 />} variant="title">
                No projects yet
              </Text>
              <Text variant="body-sm">
                A project brings its documents, tasks and people together in one place.
              </Text>
            </Column>
            <Button startIcon={<IconAdd />} onClick={create}>
              Create project
            </Button>
          </Column>
        </Surface>
      )}
    </Column>
  );
}

const sample = ['Website refresh', 'Quarterly report', 'Onboarding guide'];

Placement and icon size

Centred, the Column takes align="center", justify="center" and textAlign: 'center', with a 2.5 rem icon, in a region that gives it height, such as a Surface with a minBlockSize. At the start, under a toolbar or in a small card, it keeps the default alignment and a 1.5 rem icon.

No results

Keep the SearchField and the Select in the PageHeader's toolbar, and say the count of matches in a Text with role="status", since a neutral state isn't announced. Clear filters resets the query and the filters, then focuses the search, as the button goes with the state.

Load failed

A failure is an Alert with tone="danger" and announce, its Try again Button in actions, in the table's place. Try again moves focus to the region's heading, a Text with tabIndex={-1} and a ref, while the same Table loads as a skeleton over placeholders() rows, as the LoadFailed example does.

<Alert
  tone="danger"
  announce
  title="Couldn’t load documents"
  actions={
    <Button variant="secondary" onClick={retry}>
      Try again
    </Button>
  }
>
  Your documents are safe. Check your connection and try again.
</Alert>

A whole page

Keep the Scaffold and its navigation, and render the state as the router's page: the same Column, centred in the body, with the page's h1 as its heading, as the WholePage example does. Not found offers a Link or Button to the home page, no access a Request access Button, and offline a Try again Button that loads the page again once the connection is back.

<Scaffold topBar={<TopBar title="Northwind" />}>
  <Page>
    <Column gap={4} padding={5} align="center" justify="center" style={{ textAlign: 'center' }}>
      <Text render={<h1 />} variant="title">
        Page not found
      </Text>
      <Button variant="secondary" href="/">
        Go to Home
      </Button>
    </Column>
  </Page>
</Scaffold>

Accessibility

You provide

  • The heading's level, such as render={<h4 />} under a section's h3, so the state takes its place in the page's outline; its look stays the title type whatever the level, except inside a section whose own heading is a title, such as in a pane, where it is body text so it doesn't match its section.
  • A danger Alert with announce for content that failed to load, so it is announced; a neutral state isn't.
  • The right kind of state, its words and its one action.
  • A count of results in a role="status" text beside a search, since a neutral state isn't announced.
  • For a whole page, the page's one h1 and document.title saying what happened, such as "Page not found – Northwind".

envUI provides

  • The icons hidden from assistive technology, so the heading and the sentence are what is read.

Changelog

1.0.0

Empty states are built from Column, Text and Button, in place of the EmptyState component. The page is on the figure template, with a whole page and the empty states of the content guidelines.