# Empty state

> What a list, table or search shows when it has nothing: a heading, a message and an action.

Page: https://svelte-lean.vercel.app/primitives/empty · Markdown: https://svelte-lean.vercel.app/primitives/empty.md · Index: https://svelte-lean.vercel.app/llms.txt

What a list, a table or a search result shows when it has nothing: a title, a message, an optional action and a decorative icon.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/empty.css';
</script>

<div data-slean="empty" data-variant="outlined">
	<span data-slean-part="icon" aria-hidden="true"></span>
	<h3 data-slean-part="title">No invoices yet</h3>
	<p data-slean-part="description">Invoices appear here after the first payment.</p>
	<div data-slean-part="actions">
		<button type="button" data-slean="button">Set up billing</button>
		<button type="button" data-slean="button" data-variant="text" data-color="neutral">Read about billing</button>
	</div>
</div>
```

Left empty, the icon part draws a tray; the outline variant adds the dashed border.

## Examples

### After a search

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/empty.css';
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';

	const projects = ['Harbour survey', 'Lighthouse lamps', 'Pier repairs', 'Tide tables'];
	let query = $state('lantern');
	const matches = $derived(
		projects.filter((p) => p.toLowerCase().includes(query.trim().toLowerCase()))
	);
</script>

<div data-slean="field">
	<label for="empty-search" data-slean-part="label">Search projects</label>
	<input id="empty-search" type="search" data-slean="input" bind:value={query} />
</div>

<ul class="results">
	{#each matches as project (project)}
		<li>{project}</li>
	{/each}
</ul>

<!-- The status region is always in the page; what is written into it is announced. -->
<div role="status">
	{#if matches.length === 0}
		<div data-slean="empty">
			<span data-slean-part="icon" aria-hidden="true">
				<svg viewBox="0 0 24 24" width="24" height="24">
					<circle cx="10.5" cy="10.5" r="6" fill="none" stroke="currentColor" stroke-width="2" />
					<path d="M15 15l5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
				</svg>
			</span>
			<p data-slean-part="title">No projects match “{query}”</p>
			<p data-slean-part="description">Check the spelling or clear the search.</p>
			<div data-slean-part="actions">
				<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" onclick={() => (query = '')}>
					Clear search
				</button>
			</div>
		</div>
	{/if}
</div>

<style>
	.results:empty {
		display: none;
	}
</style>
```

The role=status region is in the page before the search, so the empty state written into it is announced. Clear the search to see the results.

### In a table

```svelte
<script lang="ts">
	import '@svelte-lean/styles/empty.css';
</script>

<table>
	<thead>
		<tr><th>Invoice</th><th>Date</th><th>Amount</th></tr>
	</thead>
	<tbody>
		<tr>
			<td colspan="3">
				<div data-slean="empty">
					<p data-slean-part="title">No invoices in September</p>
					<p data-slean-part="description">Pick another month to see its invoices.</p>
				</div>
			</td>
		</tr>
	</tbody>
</table>

<style>
	table {
		inline-size: 100%;
		border-collapse: collapse;
	}

	th {
		padding: 0.5rem 0.75rem;
		border-block-end: 1px solid var(--slean-border);
		text-align: start;
	}
</style>
```

One cell spans every column, so the table keeps its headers.

### In a card

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/card.css';
	import '@svelte-lean/styles/empty.css';
</script>

<section data-slean="card" aria-labelledby="empty-team-title">
	<header data-slean-part="header">
		<h3 id="empty-team-title" data-slean-part="title">Team</h3>
		<p data-slean-part="description">People who can edit this workspace.</p>
	</header>
	<div data-slean-part="body">
		<div data-slean="empty">
			<p data-slean-part="title">You are the only member</p>
			<p data-slean-part="description">Invite people to share the workspace with them.</p>
			<div data-slean-part="actions">
				<button type="button" data-slean="button" data-size="small">Invite people</button>
			</div>
		</div>
	</div>
</section>
```

### Text only

```svelte
<script lang="ts">
	import '@svelte-lean/styles/empty.css';
</script>

<div data-slean="empty">
	<p data-slean-part="title">No notifications</p>
	<p data-slean-part="description">You are up to date.</p>
</div>
```

Every part is optional; a small panel needs only the title.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/empty.css';
```

Render the empty state where the content would be, with a heading at the level that fits the page outline, or a `<p>` title inside a table cell, a menu or a small panel. Say why the place is empty and offer the action that fills it.

When the empty state replaces results after a search or a filter, write it into a `role="status"` region that is already in the page; the empty state itself has no role.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div data-slean="empty">` | – | yes | data-variant="outlined" draws a dashed border. No role of its own. |
| icon | `<span aria-hidden="true">` | `icon` | no | Empty: a drawn tray. With an \<svg\>: sized to 1.5rem. |
| title | `a heading, or <p>` | `title` | no | What is empty, at the heading level that fits the page. |
| description | `<p>` | `description` | no | Why, and what fills it. |
| actions | `<div> of links or buttons` | `actions` | no | Centred; wraps on narrow widths. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | anywhere | Reaches the actions (native); the empty state is not focusable |

## Styling

`empty.css` centres the parts, draws the icon circle and the default tray, sets the title and description text and the dashed outline variant. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, margin-block-end |
| `--slean-space-8` | `calc(var(--slean-spacing) * 10)` | padding-block |
| `--slean-space-6` | `calc(var(--slean-spacing) * 6)` | padding-inline |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-leading` | `1.5` | line-height |
| `--slean-border-strong` | `var(--slean-neutral-8)` | border |
| `--slean-radius-lg` | `calc(var(--slean-radius) * 1.4)` | border-radius |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-end-start-radius, border-end-end-radius |
| `--slean-text-md` | `1rem` | font-size |
| `--slean-font-weight-semibold` | `600` | font-weight |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | margin-block-start |

State selectors the stylesheet targets, all from the platform or ARIA: `:empty`.

Variant attributes: `data-variant` (`outlined`).

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `headings`, `links and buttons`, `:empty`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `a heading, a paragraph and an optional action`

Tier 0: the empty state is content with a layout. It has no behavior module, the Vite plugin maps `empty` to no module, and the page ships no Svelte Lean JavaScript for it.

Why this implementation

An empty list is still a place in the page: the reader should learn what would be there, why it is not, and what to do about it. That is a heading, a paragraph and a link or a button; the application renders it when its collection is empty, and no state or script is involved.

`empty.css` centres the parts in a column, sizes the icon, keeps the message to a readable width and wraps the actions. The contract covers what markup alone does not decide: the heading level, the icon staying decorative, and how an empty search result is announced.

### The browser owns

- the heading in the page outline, the paragraph, the link or button action
- hiding the decorative icon (aria-hidden)

### Svelte Lean owns

- empty.css: the centred column, the icon circle and its drawn tray, the outlined variant
- the contract: heading level, what to say, how a changed result is announced
- documentation

Accessibility

- The title is a real heading (or a `<p>` where a heading does not fit), so the empty section is found by heading navigation.
- The icon is `aria-hidden="true"` and says nothing the text does not.
- Showing an empty state never moves focus; its actions are native links and buttons in the tab order.
- An empty result after a search is announced through a `role="status"` region that exists before the search.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Headings, paragraphs, links, buttons` | Widely available | Not applicable |
| `:empty with generated content` | Widely available | Not applicable |

Without JavaScript

Fully functional: the text is markup and the actions are native links or buttons.

Server rendering and hydration

Static markup: the server renders the empty state when the collection is empty.

Nothing is attached; the empty state reads the same before and after hydration.

Compatibility

Headings, paragraphs, links, buttons, flex layout and generated content on `:empty` are Baseline widely available.

Tests and source

- `apps/playground/tests/primitives/display-extra.spec.ts` Playwright, with page JavaScript enabled and disabled: roles, names, keyboard, geometry, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/empty/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/empty/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/empty.css` the optional stylesheet
