Empty state
surfacesFreeMITSays that a page, panel or tile is empty: an icon on a raised key, a title, a line on what goes there, example labels and the action that starts things; dashed card or plain inside a tile, and spoken once after a search with live.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/empty-state.jsonPreview
Live demo in the dark theme.
Code
Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/empty-state.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/empty-stateUsage
import { useState } from 'react'
import { Filter, Users } from 'lucide-react'
import { DataTile } from '@/components/data-tile'
import { EmptyState } from '@/components/empty-state'
const button =
'inline-flex min-h-11 items-center rounded-xl bg-primary px-4 text-sm font-medium text-primary-foreground outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring'
const quiet =
'inline-flex min-h-11 items-center rounded-xl px-3 text-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring'
export default function EmptyStateDemo() {
const [query, setQuery] = useState('')
return (
<section aria-label="Empty states" className="grid w-full gap-4">
<EmptyState
icon={<Users />}
title="No segments yet"
description="A segment picks contacts by rules, so a campaign only reaches the people it is for."
examples={['Bought in the last 90 days', 'Customer tag: VIP', 'Opened a mail this month']}
action={
<button type="button" className={button}>
New segment
</button>
}
/>
<div className="grid gap-4 sm:grid-cols-2">
<EmptyState
density="compact"
icon={<Filter />}
title="No saved filters"
description="Save a filter from the list view to come back to it in one click."
action={
<button type="button" className={button}>
Open the list
</button>
}
secondaryAction={
<a href="#filters" className={quiet}>
How filters work
</a>
}
/>
<EmptyState density="compact" title="Nothing to review" description="New drafts show up here as soon as someone asks for a review." />
</div>
<DataTile title="Customers">
<label className="mb-3 flex flex-col gap-1.5 text-[13.5px] text-muted-foreground">
Search customers
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Try any name"
className="min-h-11 rounded-xl border border-border bg-background px-3 text-sm text-foreground"
/>
</label>
<EmptyState
variant="plain"
live={query !== ''}
title={query ? `No customers match "${query}"` : 'No customers yet'}
description={query ? 'Check the spelling, or search by email instead.' : 'Customers appear here after their first order.'}
/>
</DataTile>
</section>
)
}Source
// A page, panel or tile with nothing in it yet: an icon on a raised key, a title, one line on what goes here, a few
// example labels and the action that starts things. The dashed edge is the Tiles sign for "not real yet". `plain`
// drops the frame, so it can sit inside a data-tile or a chart's placeholder without a frame in a frame.
// Screen readers meet it as a heading with its text, then a list of examples, then the buttons. With `live`, the title
// and the description are also spoken once when it appears (after a search or a filter); the heading itself is not a
// live region, so it is not read twice.
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
import { cn } from '@/lib/utils'
import { Badge } from '@/registry/manniche/badge/badge'
export type EmptyStateLabels = {
/** The name of the list of examples, for screen readers. Default "Examples". */
examples?: string
}
export type EmptyStateProps = {
/** A decorative icon, such as a lucide icon element. Hidden from screen readers. */
icon?: ReactNode
/** What is empty, as a short line: "No segments yet". */
title: ReactNode
/** One line on what goes here and why it is worth starting. */
description?: ReactNode
/** The heading level of the title, to fit the page outline. Default 3. */
headingLevel?: 2 | 3 | 4 | 5 | 6
/** A few short example phrases, shown as demo labels. Not clickable. */
examples?: string[]
/** The main action, one button or link. */
action?: ReactNode
/** A quiet extra action, such as a link to the docs. */
secondaryAction?: ReactNode
/** "card" has the dashed frame, "plain" has none, for use inside a tile. Default "card". */
variant?: 'card' | 'plain'
/** "compact" tightens the padding and the icon. Default follows the tile. */
density?: 'comfortable' | 'compact'
/** Speak the title and description once when the empty state appears, such as after a search with no results. */
live?: boolean
/** Text overrides, with English defaults. */
labels?: EmptyStateLabels
/** Classes for the outer box. */
className?: string
}
const DEFAULT_LABELS: Required<EmptyStateLabels> = { examples: 'Examples' }
// Sizes as variables, so a compact tile (or the prop) can swap them in one place.
const METRICS =
'[--es-py:48px] [--es-px:24px] [--es-gap:10px] [--es-ic:44px] [--es-icr:12px] ' +
'group-data-[density=compact]/tile:[--es-py:28px] group-data-[density=compact]/tile:[--es-px:16px] ' +
'group-data-[density=compact]/tile:[--es-gap:8px] group-data-[density=compact]/tile:[--es-ic:36px] group-data-[density=compact]/tile:[--es-icr:10px]'
const DENSITY = {
comfortable: '[--es-py:48px] [--es-px:24px] [--es-gap:10px] [--es-ic:44px] [--es-icr:12px]',
compact: '[--es-py:28px] [--es-px:16px] [--es-gap:8px] [--es-ic:36px] [--es-icr:10px]',
}
// The raised key from Badge "new", at icon size.
const KEY =
'bg-muted text-foreground shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_7%,transparent),inset_0_-1.5px_0_rgb(0_0_0/0.14),0_0_0_1px_color-mix(in_oklab,var(--foreground)_8%,transparent)] dark:shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_7%,transparent),inset_0_-1.5px_0_rgb(0_0_0/0.45),0_0_0_1px_color-mix(in_oklab,var(--foreground)_8%,transparent)]'
/** The text of the title and description, read from the page once React has drawn them. */
function useLiveText(on: boolean, title: RefObject<HTMLElement | null>, description: RefObject<HTMLElement | null>, deps: unknown[]) {
const [text, setText] = useState('')
const said = useRef('')
const timer = useRef(0)
useEffect(() => {
if (!on) {
window.clearTimeout(timer.current)
said.current = ''
timer.current = window.setTimeout(() => setText(''), 0)
return
}
const next = [title.current?.textContent, description.current?.textContent].filter(Boolean).join('. ').trim()
if (!next || next === said.current) return
window.clearTimeout(timer.current)
// Filled a moment after the region is in the page, so it exists empty first and the text is spoken. `said` is set
// only when the text lands, so a re-render that cancels this timer schedules it again.
timer.current = window.setTimeout(() => {
said.current = next
setText(next)
}, 60)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, title, description, ...deps])
useEffect(() => () => window.clearTimeout(timer.current), [])
return text
}
/** Says that something is empty, what goes there and how to start. */
export function EmptyState({
icon,
title,
description,
headingLevel = 3,
examples,
action,
secondaryAction,
variant = 'card',
density,
live = false,
labels: labelsProp,
className,
}: EmptyStateProps) {
const labels = { ...DEFAULT_LABELS, ...labelsProp }
const Heading = `h${headingLevel}` as const
const titleRef = useRef<HTMLHeadingElement>(null)
const descriptionRef = useRef<HTMLParagraphElement>(null)
const spoken = useLiveText(live, titleRef, descriptionRef, [title, description])
return (
<div
data-variant={variant}
className={cn(
'flex min-w-0 flex-col items-center gap-(--es-gap) px-(--es-px) py-(--es-py) text-center',
density ? DENSITY[density] : METRICS,
variant === 'card' && 'rounded-[calc(var(--radius)*2+2px)] border border-dashed border-foreground/25 bg-card text-card-foreground',
className,
)}
>
{icon && (
<span
aria-hidden="true"
className={cn('mb-1 grid size-(--es-ic) flex-none place-items-center rounded-(--es-icr) [&_svg]:size-5', KEY)}
>
{icon}
</span>
)}
<Heading ref={titleRef} className="text-[15px] leading-[1.2] font-medium tracking-[-0.01em] text-balance">
{title}
</Heading>
{description && (
<p ref={descriptionRef} className="max-w-[46ch] text-[13.5px] leading-normal text-pretty text-muted-foreground">
{description}
</p>
)}
{examples && examples.length > 0 && (
<ul aria-label={labels.examples} className="mt-1 flex max-w-full flex-wrap justify-center gap-2">
{examples.map((example, i) => (
<li key={`${i}-${example}`} className="flex max-w-full min-w-0">
<Badge variant="demo" className="h-auto min-h-[22px] max-w-full py-1 whitespace-normal">
{example}
</Badge>
</li>
))}
</ul>
)}
{(action || secondaryAction) && (
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
{action}
{secondaryAction}
</div>
)}
{live && (
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{spoken}
</span>
)}
</div>
)
}Props
EmptyStateProps
| Prop | Type | Default | What it does |
|---|---|---|---|
icon | ReactNode | – | A decorative icon, such as a lucide icon element. Hidden from screen readers. |
title required | ReactNode | – | What is empty, as a short line: "No segments yet". |
description | ReactNode | – | One line on what goes here and why it is worth starting. |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | The heading level of the title, to fit the page outline. Default 3. |
examples | string[] | – | A few short example phrases, shown as demo labels. Not clickable. |
action | ReactNode | – | The main action, one button or link. |
secondaryAction | ReactNode | – | A quiet extra action, such as a link to the docs. |
variant | 'card' | 'plain' | 'card' | "card" has the dashed frame, "plain" has none, for use inside a tile. Default "card". |
density | 'comfortable' | 'compact' | – | "compact" tightens the padding and the icon. Default follows the tile. |
live | boolean | false | Speak the title and description once when the empty state appears, such as after a search with no results. |
labels | EmptyStateLabels | – | Text overrides, with English defaults. |
className | string | – | Classes for the outer box. |
A11y
Screen reader
The title is a real heading (headingLevel, default h3), the description a paragraph and the examples a list named by labels.examples (default "Examples"). The icon is aria-hidden. With live, an empty status region is in place first and gets the title and description a moment later, so they are spoken once when the empty state appears or its title changes; the heading itself is not live, so it is not read twice.
Keyboard
The component adds no tab stops of its own. The examples are labels, not buttons. Only the action and secondaryAction you pass are focusable, in that order.
Reduced motion
Nothing moves.
Other
Long example labels wrap instead of running out of the box at 320 px. Text is 15 and 13.5 px in foreground and muted-foreground on the card, which keeps 4.5:1 in light and dark.