EmptyState
Turns a blank region into an instruction. Use it for first-run views, cleared filters,
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall 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.
descriptionsays 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
smfor empties inside a card or panel,mdfor 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.
| Token | Tier | Declared as |
|---|---|---|
--dt-space-inline-sm | semantic | var(--dt-dim-3) |
--dt-space-inset-lg | semantic | var(--dt-dim-6) |
--dt-space-inset-xl | semantic | var(--dt-dim-8) |
--dt-space-stack-2xs | semantic | var(--dt-dim-1) |
--dt-space-stack-sm | semantic | var(--dt-dim-3) |
--dt-text-body-lg-size | semantic | var(--dt-font-size-lg) |
--dt-text-body-sm-family | semantic | var(--dt-font-family-sans) |
--dt-text-body-sm-line | semantic | var(--dt-line-height-sm) |
--dt-text-body-sm-size | semantic | var(--dt-font-size-sm) |
--dt-text-heading-xs-family | semantic | var(--dt-font-family-sans) |
--dt-text-heading-xs-line | semantic | var(--dt-line-height-lg) |
--dt-text-heading-xs-size | semantic | var(--dt-font-size-lg) |
--dt-text-primary | semantic | var(--dt-color-neutral-900) |
--dt-text-secondary | semantic | var(--dt-color-neutral-600) |
--dt-text-tertiary | semantic | var(--dt-color-neutral-500) |
--dt-font-weight-semibold | primitive | 600 |
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.