EmptyState

Reusable nothing-here-yet placeholder.

Live demo

01

Default

Dashed border, soft grey background.

Card

Solid white card with a soft shadow.

Minimal

No chrome β€” drops into any layout.

Welcome aboard

You haven't created any projects yet. Spin up your first one and get going.

No deployments in this branch

You're on feature/empty-state . Push a commit to trigger your first deployment.

Implementation

02
EmptyState.svelte
<script lang="ts">
  import EmptyState from '$lib/components/EmptyState.svelte';
</script>
​
<EmptyState title="No orders yet">
  {#snippet icon()}πŸ“¦{/snippet}
  {#snippet description()}Place your first order to see it here.{/snippet}
  {#snippet action()}<button>Browse catalogue</button>{/snippet}
</EmptyState>

EmptyState is a snippet-driven placeholder for any 'nothing-here-yet' moment β€” empty inboxes, no search results, fresh dashboards. Pass an icon snippet (anything: emoji, SVG, image), a description snippet (any markup), and an optional action snippet for a CTA. Three variants control chrome (default dashed, card with shadow, minimal no-chrome) and three sizes scale the whole thing without re-typing styles. role='status' + aria-live='polite' lets screen readers announce the state when it appears.

Logic explainer

03

What Does It Do? (Plain English)

EmptyState is the friendly "nothing here yet" placeholder that turns a blank region into a useful, communicative one. Drop it into an empty inbox, a search with no results, an onboarding panel that hasn't been filled out β€” anywhere a section of your UI has nothing to render β€” and it gives the user three things at once: a visual cue (icon), a clear sentence about why it's empty, and an optional path forward (a CTA).

Think of it as the UI equivalent of a courteous shop assistant noticing you can't find what you're looking for, and offering directions.

How It Works (Pseudo-Code)

props:
  title       = string heading (optional)
  size        = 'sm' | 'md' | 'lg'           // default 'md'
  variant     = 'default' | 'card' | 'minimal'  // default 'default'
  icon        = optional snippet
  description = optional snippet
  action      = optional snippet
  class       = extra classes

render <section role="status" aria-live="polite">
  if icon:        <div class="empty-icon">{render icon}</div>
  if title:       <h3 class="empty-title">{title}</h3>
  if description: <p  class="empty-description">{render description}</p>
  if action:      <div class="empty-action">{render action}</div>
</section>

There is no internal state, no event handling, no effect. The component is purely presentational β€” its job is to lay out the four canonical pieces of an empty state in a consistent, accessible way.

The Core Concept: Three Sizes Γ— Three Variants As Composable Surface Treatments

EmptyState exists in nine layout combinations from a tiny prop set. The clever bit is that size and variant address two orthogonal axes:

                size=sm                  size=md                  size=lg
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
default       β”‚ dashed   β”‚             β”‚ dashed   β”‚             β”‚ dashed   β”‚
              β”‚ box, sm  β”‚             β”‚ box, md  β”‚             β”‚ box, lg  β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

card          β”‚ solid    β”‚             β”‚ solid    β”‚             β”‚ solid    β”‚
              β”‚ shadow,  β”‚             β”‚ shadow,  β”‚             β”‚ shadow,  β”‚
              β”‚ sm       β”‚             β”‚ md       β”‚             β”‚ lg       β”‚

minimal       β”‚ no chromeβ”‚             β”‚ no chromeβ”‚             β”‚ no chromeβ”‚
              β”‚ sm       β”‚             β”‚ md       β”‚             β”‚ lg       β”‚
  • Size controls vertical breathing room and the icon/title/description font scales. sm is for inline messages inside a list, md is the default card-sized empty state, lg fills a dashboard-sized region.
  • Variant controls the surface chrome. default adds a dashed border (signals "this is a placeholder"), card adds a solid panel (looks like a real piece of UI), minimal strips chrome (lets the parent layout breathe).

Pick one of each, and you get the right empty state for the context. Same icon, title, and message β€” different visual weight.

Snippet-Driven Slots

The icon, description, and action are snippets, not strings. This is deliberate: an icon could be an emoji, a SVG, an <img>, or a full sub-component. A description could be a sentence with bold text or a link. An action could be a single button or a row of two.

<EmptyState title="No results">
  {#snippet icon()}πŸ”{/snippet}
  {#snippet description()}
    Try a different term β€” see <a href="/help">search tips</a>.
  {/snippet}
  {#snippet action()}
    <button onclick={clearFilters}>Clear filters</button>
    <button onclick={resetSearch}>Start over</button>
  {/snippet}
</EmptyState>

By using snippets rather than iconHtml strings or IconComponent props, EmptyState avoids two trapdoors: it never needs to dangerously inject HTML, and it never needs to take a prop type for "anything renderable". The Svelte 5 snippet primitive does both safely.

Accessibility Notes

The wrapper is a <section> with role="status" and aria-live="polite". When EmptyState appears (e.g. after the user applies a filter that returns zero results), screen readers announce its content politely β€” the title, then the description.

The icon is aria-hidden="true" because it's purely decorative. If the icon carried meaning the title doesn't (rare in practice), the caller can omit aria-hidden from inside the snippet.

The title uses <h3>, which assumes the page has an <h1>/<h2> higher up. If the empty state is the only content on the page (very rare), use a wrapping component that renders an <h1>.

Action buttons in the action snippet are normal interactive controls β€” they're keyboard-focusable and announce themselves the way buttons always do. EmptyState doesn't try to manage focus; the user reaches the CTA via Tab.

Distinct From SkeletonLoader

These two answer related but distinct questions:

  • SkeletonLoader is for "we have data on the way, here's a placeholder of its shape so the layout doesn't jump when it arrives". Used during loading.
  • EmptyState is for "the data has arrived, and there isn't any". Used after loading.

If your fetch hasn't returned, use SkeletonLoader. If your fetch returned an empty array, use EmptyState. They often appear in the same component, behind different conditions:

{#if loading}
  <SkeletonLoader shape="rect" height="200px" />
{:else if items.length === 0}
  <EmptyState title="No items" ... />
{:else}
  <ItemList {items} />
{/if}

State Flow Diagram

       Parent decides to show empty state
                    β”‚
                    β”‚  (e.g. items.length === 0)
                    β–Ό
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚   EmptyState    β”‚
            β”‚   renders       β”‚
            β”‚  role="status"  β”‚
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚ aria-live="polite"
                     β–Ό
        Screen reader announces:
        "<title>. <description>."
                     β”‚
                     β”‚ user taps action button (if present)
                     β–Ό
            Action handler runs
            (parent unmounts EmptyState
             once data arrives or the
             empty condition resolves)

EmptyState has no internal states β€” it is always either rendered or not, and the parent decides.

Props Reference

Prop Type Default Description
title string '' Bold heading line. Empty string skips the <h3>.
size 'sm' | 'md' | 'lg' 'md' Drives vertical padding and font sizes.
variant 'default' | 'card' | 'minimal' 'default' Surface chrome β€” dashed, solid, or none.
icon Snippet undefined Leading visual. Emoji, SVG, <img>, anything.
description Snippet undefined Body copy under the title. May contain inline links.
action Snippet undefined CTA region β€” typically one or two buttons.
class string '' Extra classes appended to the section.

Edge Cases

Situation Behaviour
All snippets and title are empty Renders an empty <section> with chrome but no content. Looks like an empty card β€” not useful, but not broken. The component never errors.
Description contains a focusable link Tab order is normal β€” the link is reachable and announced. EmptyState does not steal or trap focus.
Multiple action buttons in the action snippet Both render side-by-side; CSS stacks them with a small gap. Authors are responsible for visual hierarchy (primary vs. secondary).
User has prefers-reduced-motion: reduce Nothing changes β€” EmptyState has no animations to disable.
EmptyState appears repeatedly as filters change Each appearance triggers a fresh aria-live announcement. Aggressive filter changes can produce chatty announcements; consider debouncing the parent.
variant="minimal" inside a parent that already has a border Use this combination deliberately β€” the minimal variant strips chrome so the parent can supply it. Avoids double-bordering.
Icon snippet renders a large image The icon container has no max-size; the image will set the layout. Constrain the image inside the snippet (width: 64px;) if you need a fixed size.

Dependencies

  • Svelte 5.x β€” $props, snippets. Renders entirely from props.
  • Zero external runtime dependencies. Pure scoped CSS.

File Structure

src/lib/components/EmptyState.svelte         # component implementation
src/lib/components/EmptyState.md             # this file (rendered inside ComponentPageShell)
src/lib/components/EmptyState.test.ts        # vitest unit tests
src/routes/emptystate/+page.svelte           # demo page

API

04
PropTypeDefaultDescription
titlestringβ€”Heading text rendered above the description.
variant"default" | "card" | "minimal""default"Chrome style β€” dashed default, solid card, or no chrome.
size"sm" | "md" | "lg""md"Padding, icon size, and font scale.
iconSnippetβ€”Icon slot β€” emoji, inline SVG, or img tag.
descriptionSnippetβ€”Body content β€” supports any markup.
actionSnippetβ€”Optional CTA slot β€” typically a button or link.
classstring""Extra class names forwarded to the root element.