Skip to content

Empty State

Empty State gives a valid surface a calm layout when it currently has nothing to show. The application writes the semantic heading, the specific reason, and the real next action. Klean does not hide those decisions behind component anatomy.

It is one component, not a family of EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, or EmptyContent wrappers. There is no media variant, action schema, loading prop, error prop, illustration package, or product copy.

ProjectsEmptyState.vue
First use, filtered zero results, and different product voices share a layout boundary without becoming the same message.

Installation

The command detects Vue, React, or Svelte and copies the matching one-file source into the conventional UI directory.

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add empty-state

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

There is no initializer, provider, klean-ui.json, class helper, barrel file, or runtime Klean dependency.

Usage

Write the same ordinary document markup you would use without a component. Empty State supplies the surrounding layout and class-merging seam.

Vue

ProjectsEmptyState.vue
<script setup>
import { Link } from '@inertiajs/vue3'
import EmptyState from '@/components/ui/empty-state/EmptyState.vue'
</script>

<template>
  <EmptyState as="section" aria-labelledby="projects-empty-title">
    <svg aria-hidden="true"><!-- Application icon --></svg>
    <div>
      <h2 id="projects-empty-title">No projects yet</h2>
      <p>Create your first project to deploy an application.</p>
    </div>
    <Link href="/projects/new">Create project</Link>
  </EmptyState>
</template>

React

ProjectsEmptyState.jsx
import { Link } from '@inertiajs/react'
import EmptyState from '@/components/ui/empty-state/EmptyState.jsx'

export function ProjectsEmptyState() {
  return (
    <EmptyState as="section" aria-labelledby="projects-empty-title">
      <svg aria-hidden="true">{/* Application icon */}</svg>
      <div>
        <h2 id="projects-empty-title">No projects yet</h2>
        <p>Create your first project to deploy an application.</p>
      </div>
      <Link href="/projects/new">Create project</Link>
    </EmptyState>
  )
}

Svelte

ProjectsEmptyState.svelte
<script>
  import { Link } from '@inertiajs/svelte'
  import EmptyState from '@/components/ui/empty-state/EmptyState.svelte'
</script>

<EmptyState as="section" aria-labelledby="projects-empty-title">
  <svg aria-hidden="true"><!-- Application icon --></svg>
  <div>
    <h2 id="projects-empty-title">No projects yet</h2>
    <p>Create your first project to deploy an application.</p>
  </div>
  <Link href="/projects/new">Create project</Link>
</EmptyState>

API

PurposeVueReactSvelte
Truthful elementasasas
Contentdefault slotchildrenchildren snippet
Root stylingclassclassNameclass

as defaults to div, which adds no landmark or heading assumption. Pass section, article, or a framework component only when that element truthfully fits the surrounding document. Native attributes and events pass through unchanged.

There are no title, description, icon, or actions props. Those are content, and content should remain visible in the caller's markup.

Name the reason

“Nothing here” is rarely enough. Tell the user which condition they are in and what can happen next.

  • First use: “No projects yet” may offer a Create project Link.
  • Filtered zero: “No records match ‘worker’” should offer Clear filters when possible.
  • Completed work: “You are all caught up” may need no action.
  • Unavailable capability: explain the prerequisite or authorized next destination rather than pretending the collection is merely empty.

Keep the title scannable and the description short. Do not reuse cheerful onboarding copy for a filtered search that found nothing.

Loading, empty, and error are different

Render Empty State only after the request has succeeded and the valid surface is genuinely empty.

  • While content is pending, preserve the region and use Spinner or the forthcoming Loading State composition.
  • When the request failed, show a recoverable error with a truthful retry or navigation action.
  • When stale content remains visible during a partial reload, do not replace it with Empty State.

This boundary prevents empty content from flashing during navigation and prevents failures from being mistaken for “zero records.”

Use a native anchor or framework-native Inertia Link for destinations. Use a button for commands such as clearing filters or retrying a local derivation. Empty State never converts an action description into a callback.

Render only actions authorized by the current server response. If an action is unavailable, update the explanation too; hiding “Create project” while leaving “Get started by creating a project” is misleading.

Keyboard and accessibility

  • The root has no role or live region by default, so server-rendered empty content is not announced twice.
  • The caller owns heading hierarchy. Use the heading level that fits the surrounding page or region.
  • Name a section with aria-labelledby when it should be a discoverable region.
  • Decorative icons use aria-hidden="true"; meaningful images receive useful alternative text.
  • Links and buttons retain native focus, keyboard activation, and modified-click behavior.
  • Dynamically appearing emptiness normally does not need interruption. Add a targeted status message only when the interaction genuinely requires an announcement.

Styling with Tailwind

The baseline centers a wrapping column with comfortable space. class or className merges onto that root. Every icon, heading, description, Link, and button is caller markup styled with ordinary Tailwind.

Use classes such as min-h-0 py-8 for a compact table state, a border and background for a card, or a larger minimum height for a page. There is no variant, compact, fullPage, media, tone, or product theme prop.

When to use

Use Empty State when a successfully loaded collection, page, panel, table, or workflow has nothing meaningful to render and a short explanation helps the user understand why.

When not to use

  • Use Spinner or preserve stale content while data is loading.
  • Use Alert for important contextual guidance around content that still exists.
  • Use Toast for transient feedback after an action.
  • Use Command or Combobox built-in no-result content inside those interactions.
  • Do not use an illustration-heavy first-use panel for a frequently repeated filtered-zero result.

Complete framework source

Vue

EmptyState.vue
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

defineProps({
  /** Native element or framework component that truthfully fits the document. */
  as: { type: [String, Object, Function], default: 'div' }
})

const attrs = useAttrs()
const element = ref()
const rootAttrs = computed(() => {
  const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
  return rest
})

defineExpose({ element })
</script>

<template>
  <component
    :is="as"
    ref="element"
    v-bind="rootAttrs"
    data-slot="empty-state"
    :class="
      twMerge(
        'flex min-h-48 w-full flex-col items-center justify-center gap-4 p-6 text-center text-gray-950 dark:text-white',
        attrs.class
      )
    "
  >
    <slot />
  </component>
</template>

React

EmptyState.jsx
import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'

const BASE_CLASSES =
  'flex min-h-48 w-full flex-col items-center justify-center gap-4 p-6 text-center text-gray-950 dark:text-white'

const EmptyState = forwardRef(function EmptyState(
  { as: Component = 'div', className, 'data-slot': _dataSlot, ...props },
  ref
) {
  return (
    <Component
      {...props}
      ref={ref}
      data-slot="empty-state"
      className={twMerge(BASE_CLASSES, className)}
    />
  )
})

export default EmptyState

Svelte

EmptyState.svelte
<script>
  import { twMerge } from "tailwind-merge";

  const BASE_CLASSES =
    "flex min-h-48 w-full flex-col items-center justify-center gap-4 p-6 text-center text-gray-950 dark:text-white";

  let {
    as = "div",
    children,
    class: className,
    "data-slot": _dataSlot,
    ...rootProps
  } = $props();

  let element = $state();

  export function getElement() {
    return element;
  }
</script>

{#if typeof as === "string"}
  <svelte:element
    this={as}
    {...rootProps}
    bind:this={element}
    data-slot="empty-state"
    class={twMerge(BASE_CLASSES, className)}
  >
    {@render children?.()}
  </svelte:element>
{:else}
  {@const Component = as}
  <Component
    {...rootProps}
    bind:this={element}
    data-slot="empty-state"
    class={twMerge(BASE_CLASSES, className)}
  >
    {@render children?.()}
  </Component>
{/if}

  • DataTable — owns the successfully loaded collection and selection around a table empty state.
  • Filter Bar — owns the committed filters that a filtered-zero action may clear.
  • Button — represents a caller-owned command such as clearing filters.
  • Card — can provide a visual surface around an Empty State without changing its meaning.
  • Spinner — communicates pending work instead of absent content.
  • Alert — provides contextual warning or guidance when content still exists.

All open source projects are released under the MIT License.