Empty
Zero-state scaffold. Compose Header, Media, Title, Description, and Content on a dashed-border surface — everything optional. No motion; the content does the emotional work.
Basic
<script lang="ts">
import * as Empty from '$lib/components/ui/empty';
import { Icon } from '$lib/components/ui/icon';
</script>
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<Icon name="inbox-line" />
</Empty.Media>
<Empty.Title>No messages yet</Empty.Title>
<Empty.Description>When you start a conversation, it'll land here.</Empty.Description>
</Empty.Header>
</Empty.Root>With action
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty"
role="status"
aria-live="polite"
class={cn(
'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-lg border-dashed p-12 text-center text-balance',
className
)}
{...restProps}
>
{@render children?.()}
</div>
Outline
Dashed border around the full surface — use on solid backgrounds where the empty state needs a boundary.
<Empty.Root class="w-full max-w-md border border-dashed">
<Empty.Header>
<Empty.Media variant="icon">
<Icon name="inbox-line" />
</Empty.Media>
<Empty.Title>No messages yet</Empty.Title>
<Empty.Description>Outlined surface — use when the empty state sits on a solid background and needs a boundary.</Empty.Description>
</Empty.Header>
</Empty.Root>Background
Muted tinted surface — quieter than the dashed outline, blends into card layouts.
<Empty.Root class="w-full max-w-md rounded-lg bg-muted/40">
<Empty.Header>
<Empty.Media variant="icon">
<Icon name="inbox-line" />
</Empty.Media>
<Empty.Title>No messages yet</Empty.Title>
<Empty.Description>Tinted surface — blends into card layouts without a dashed border.</Empty.Description>
</Empty.Header>
</Empty.Root>Avatar
Swap the icon media for a single Avatar — good for onboarding flows where a real profile will land here.
<Empty.Root class="w-full max-w-md border border-dashed">
<Empty.Header>
<Avatar class="size-12">
<AvatarImage src="https://avatars.githubusercontent.com/u/1?v=4" alt="" />
<AvatarFallback>UB</AvatarFallback>
</Avatar>
<Empty.Title>Invite your first teammate</Empty.Title>
<Empty.Description>Collaborators show up here after they accept the invite.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button size="sm"><Icon name="user-add-line" /> Send invite</Button>
</Empty.Content>
</Empty.Root>Avatar group
Stack multiple avatars as the media — the empty state is about a collection, not a single entity.
<Empty.Root class="w-full max-w-md border border-dashed">
<Empty.Header>
<AvatarGroup>
<Avatar><AvatarFallback>SA</AvatarFallback></Avatar>
<Avatar><AvatarFallback>KL</AvatarFallback></Avatar>
<Avatar><AvatarFallback>RM</AvatarFallback></Avatar>
</AvatarGroup>
<Empty.Title>No shared workspaces</Empty.Title>
<Empty.Description>Your teammates will appear here once a workspace is shared.</Empty.Description>
</Empty.Header>
</Empty.Root>Input group
Compose an InputGroup inside Content to let users retry the query that landed them here.
<Empty.Root class="w-full max-w-md border border-dashed">
<Empty.Header>
<Empty.Media variant="icon">
<Icon name="search-line" />
</Empty.Media>
<Empty.Title>No results</Empty.Title>
<Empty.Description>Refine your query to widen the net.</Empty.Description>
</Empty.Header>
<Empty.Content>
<InputGroup.Root class="w-full">
<InputGroup.Addon align="inline-start"><Icon name="search-line" class="size-4" size="1rem" /></InputGroup.Addon>
<InputGroup.Input placeholder="Search again…" />
</InputGroup.Root>
</Empty.Content>
</Empty.Root>Search — no results
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="empty"
role="status"
aria-live="polite"
class={cn(
'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-lg border-dashed p-12 text-center text-balance',
className
)}
{...restProps}
>
{@render children?.()}
</div>
Action with secondary link
Pair the primary action with a quieter link below — the secondary path stays one tap away without competing for attention.
<Empty.Root>
<Empty.Header>...</Empty.Header>
<Empty.Content>
<div class="flex flex-col items-center gap-2">
<Button size="sm"><Icon name="add-line" /> New project</Button>
<a href="#" class="text-xs text-muted-foreground underline">Import from existing repo</a>
</div>
</Empty.Content>
</Empty.Root>In a data table
Render Empty inside a single full-width row when a Table has no data — the table header stays visible so users can see what would have appeared.
| Invoice | Status | Amount |
|---|---|---|
No invoices yet Once you raise an invoice, it'll appear here. | ||
<Table.Body>
<Table.Row>
<Table.Cell colspan={3} class="p-0">
<Empty.Root class="rounded-none border-none">
<Empty.Header>
<Empty.Media variant="icon"><Icon name="inbox-line" /></Empty.Media>
<Empty.Title>No invoices yet</Empty.Title>
</Empty.Header>
</Empty.Root>
</Table.Cell>
</Table.Row>
</Table.Body>Empty.Root props
Root accepts native div attributes. Header, Title, Description, and Content accept native div attributes plus `class`.
| Prop | Type | Default | Description |
|---|---|---|---|
string | — | Merged onto the root via tailwind-merge. | |
HTMLDivElement | null | null | Two-way-bindable element reference. |
Empty.Media props
| Prop | Type | Default | Description |
|---|---|---|---|
'default' | 'icon' | 'default' | Icon variant renders a 40px muted square with a 24px icon inside. Default is an unstyled wrapper for custom artwork. | |
string | — | Merged via tailwind-merge. |