EmptyState

Turns a blank region into an instruction. Use it for first-run views, cleared filters,

Display EmptyState.jsx EmptyState.d.ts EmptyState.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

EmptyState is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { EmptyState } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Turns a blank region into an instruction. Use it for first-run views, cleared filters, zero search results, and permission gaps.

Rules

  • The three empties are different. First run needs a create action; no results needs a way to widen the search; no access needs a way to request it. Do not ship one generic empty state for all three.
  • description says why it is empty and what happens next. "No data" says neither.
  • One primary action. If there are two equal paths forward, the view has a design problem upstream.
  • Size sm for empties inside a card or panel, md for a full page region.

Tradeoffs

An illustrated empty state is memorable on first run and tiresome on the fiftieth. For states a user hits routinely, such as a cleared filter or an empty inbox, keep it to text and one control.

Props

The typed contract, from EmptyState.d.ts.

import * as React from "react";

/** Placeholder for a view with no content yet, no results, or no access. */
export interface EmptyStateProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  /** What is empty, in sentence case. */
  title: React.ReactNode;
  /** Why it is empty and what to do next. */
  description?: React.ReactNode;
  /** Lucide icon element, sized from --dt-size-icon-*. */
  icon?: React.ReactNode;
  /** Primary action — usually the thing that fills the empty space. */
  action?: React.ReactNode;
  /** Optional secondary action, e.g. "Learn more". */
  secondaryAction?: React.ReactNode;
  /** @default "md" */
  size?: "sm" | "md";
}

export declare function EmptyState(props: EmptyStateProps): React.JSX.Element;

Tokens it reads

Every custom property EmptyState.jsx resolves, read from the source: 15 semantic, 1 primitive. Component tier first, because that is the one to reach for.

  • semantic Shared roles. Override one and everything using that role moves with it.
  • primitive Raw values. A component reading one directly is a deliberate exception.
TokenTierDeclared as
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-inset-lgsemanticvar(--dt-dim-6)
--dt-space-inset-xlsemanticvar(--dt-dim-8)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-space-stack-smsemanticvar(--dt-dim-3)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-font-weight-semiboldprimitive600

Source

React only, no dependencies, every value a token.

import React from "react";

export function EmptyState({ title, description, icon, action, secondaryAction, size = "md", style, ...rest }) {
  const pad = size === "sm" ? "var(--dt-space-inset-lg)" : "var(--dt-space-inset-xl)";
  return (
    <div style={{
      display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center",
      gap: "var(--dt-space-stack-sm)", padding: pad, ...style,
    }} {...rest}>
      {icon && <span aria-hidden="true" style={{ color: "var(--dt-text-tertiary)", display: "flex" }}>{icon}</span>}
      <span style={{
        fontFamily: "var(--dt-text-heading-xs-family)", fontSize: size === "sm" ? "var(--dt-text-body-lg-size)" : "var(--dt-text-heading-xs-size)",
        lineHeight: "var(--dt-text-heading-xs-line)", fontWeight: "var(--dt-font-weight-semibold)", color: "var(--dt-text-primary)",
      }}>{title}</span>
      {description && (
        <p style={{
          margin: 0, maxWidth: "46ch",
          fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)",
          lineHeight: "var(--dt-text-body-sm-line)", color: "var(--dt-text-secondary)", textWrap: "pretty",
        }}>{description}</p>
      )}
      {(action || secondaryAction) && (
        <span style={{ display: "flex", gap: "var(--dt-space-inline-sm)", marginTop: "var(--dt-space-stack-2xs)" }}>
          {action}{secondaryAction}
        </span>
      )}
    </div>
  );
}

In context

EmptyState also appears on the display reference cards, alongside the rest of its family.