Styled empty state with header, media, title, description and content.

Preview
Style
Basic
No projects yet
You haven't created any projects yet. Get started by creating your first project.
With Muted Background
No results found
No results found for your search. Try adjusting your search terms.
With Border
404 - Not Found
The page you're looking for doesn't exist. Try searching for what you need below.
/
Need help? Contact support
With Icon
Nothing to see here
No posts have been created yet. Get started by creating your first post.
With Muted Background Alt
404 - Not Found
The page you're looking for doesn't exist. Try searching for what you need below.
/
Need help? Contact support
In Card
No projects yet
You haven't created any projects yet. Get started by creating your first project.
src/demo/views/empty.ts344 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

import { button } from '../../generated/registry/ui/button'
import { Empty } from '../../generated/registry/ui/empty'
import { icon } from '../../generated/registry/lib/icons'
import { ArrowUpRight, Folder, Plus, Search } from 'lucide'

import { defineSlice } from '../slice'
import type { Message, Model } from '../assemble'

export const emptyView = (_model: Model, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          Empty<Message>(
            {},
            [
              Empty.header<Message>(
                {},
                [
                  Empty.title<Message>({}, ['No projects yet'], h),
                  Empty.description<Message>(
                    {},
                    [
                      "You haven't created any projects yet. Get started by creating your first project.",
                    ],
                    h,
                  ),
                ],
                h,
              ),
              Empty.content<Message>(
                {},
                [
                  h.div(
                    [h.Class('flex gap-2')],
                    [
                      button<Message>({}, 'Create project', h),
                      button<Message>({ variant: 'outline' }, 'Import project', h),
                    ],
                  ),
                  button<Message>(
                    { variant: 'link', className: 'text-muted-foreground' },
                    h.span(
                      [h.Class('inline-flex items-center gap-1')],
                      ['Learn more ', icon(h, ArrowUpRight, 'size-4')],
                    ),
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div(
            [h.Class('px-1 text-xs font-medium text-muted-foreground')],
            ['With Muted Background'],
          ),
          Empty<Message>(
            { className: 'bg-muted' },
            [
              Empty.header<Message>(
                {},
                [
                  Empty.title<Message>({}, ['No results found'], h),
                  Empty.description<Message>(
                    {},
                    ['No results found for your search. Try adjusting your search terms.'],
                    h,
                  ),
                ],
                h,
              ),
              Empty.content<Message>(
                {},
                [
                  button<Message>({}, 'Try again', h),
                  button<Message>(
                    { variant: 'link', className: 'text-muted-foreground' },
                    h.span(
                      [h.Class('inline-flex items-center gap-1')],
                      ['Learn more ', icon(h, ArrowUpRight, 'size-4')],
                    ),
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Border']),
          Empty<Message>(
            { className: 'border' },
            [
              Empty.header<Message>(
                {},
                [
                  Empty.title<Message>({}, ['404 - Not Found'], h),
                  Empty.description<Message>(
                    {},
                    [
                      "The page you're looking for doesn't exist. Try searching for what you need below.",
                    ],
                    h,
                  ),
                ],
                h,
              ),
              Empty.content<Message>(
                {},
                [
                  h.div(
                    [
                      h.Class(
                        'flex w-full max-w-sm items-center gap-2 rounded-lg border bg-background px-3 py-2',
                      ),
                    ],
                    [
                      icon(h, Search, 'size-4 text-muted-foreground'),
                      h.input([
                        h.Type('text'),
                        h.Placeholder('Try searching for pages...'),
                        h.Class(
                          'flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
                        ),
                      ]),
                      h.kbd(
                        [h.Class('ml-auto rounded border bg-muted px-1.5 py-0.5 text-xs')],
                        ['/'],
                      ),
                    ],
                  ),
                  Empty.description<Message>(
                    {},
                    [
                      h.span(
                        [],
                        [
                          'Need help? ',
                          h.a([h.Href('#'), h.Class('underline')], ['Contact support']),
                        ],
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Icon']),
          Empty<Message>(
            { className: 'border' },
            [
              Empty.header<Message>(
                {},
                [
                  Empty.media<Message>({ variant: 'icon' }, [icon(h, Folder, 'size-4')], h),
                  Empty.title<Message>({}, ['Nothing to see here'], h),
                  Empty.description<Message>(
                    {},
                    [
                      h.span(
                        [],
                        [
                          'No posts have been created yet. Get started by ',
                          h.a([h.Href('#'), h.Class('underline')], ['creating your first post']),
                          '.',
                        ],
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
              Empty.content<Message>(
                {},
                [
                  button<Message>(
                    { variant: 'outline' },
                    h.span(
                      [h.Class('inline-flex items-center gap-1.5')],
                      [icon(h, Plus, 'size-4'), 'New Post'],
                    ),
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div(
            [h.Class('px-1 text-xs font-medium text-muted-foreground')],
            ['With Muted Background Alt'],
          ),
          Empty<Message>(
            { className: 'bg-muted/50' },
            [
              Empty.header<Message>(
                {},
                [
                  Empty.title<Message>({}, ['404 - Not Found'], h),
                  Empty.description<Message>(
                    {},
                    [
                      "The page you're looking for doesn't exist. Try searching for what you need below.",
                    ],
                    h,
                  ),
                ],
                h,
              ),
              Empty.content<Message>(
                {},
                [
                  h.div(
                    [
                      h.Class(
                        'flex w-full max-w-sm items-center gap-2 rounded-lg border bg-background px-3 py-2',
                      ),
                    ],
                    [
                      icon(h, Search, 'size-4 text-muted-foreground'),
                      h.input([
                        h.Type('text'),
                        h.Placeholder('Try searching for pages...'),
                        h.Class(
                          'flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
                        ),
                      ]),
                      h.kbd(
                        [h.Class('ml-auto rounded border bg-muted px-1.5 py-0.5 text-xs')],
                        ['/'],
                      ),
                    ],
                  ),
                  Empty.description<Message>(
                    {},
                    [
                      h.span(
                        [],
                        [
                          'Need help? ',
                          h.a([h.Href('#'), h.Class('underline')], ['Contact support']),
                        ],
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['In Card']),
          Empty<Message>(
            { className: 'border' },
            [
              Empty.header<Message>(
                {},
                [
                  Empty.media<Message>({ variant: 'icon' }, [icon(h, Folder, 'size-4')], h),
                  Empty.title<Message>({}, ['No projects yet'], h),
                  Empty.description<Message>(
                    {},
                    [
                      "You haven't created any projects yet. Get started by creating your first project.",
                    ],
                    h,
                  ),
                ],
                h,
              ),
              Empty.content<Message>(
                {},
                [
                  h.div(
                    [h.Class('flex gap-2')],
                    [
                      button<Message>({}, 'Create project', h),
                      button<Message>({ variant: 'outline' }, 'Import project', h),
                    ],
                  ),
                  button<Message>(
                    { variant: 'link', className: 'text-muted-foreground' },
                    h.span(
                      [h.Class('inline-flex items-center gap-1')],
                      ['Learn more ', icon(h, ArrowUpRight, 'size-4')],
                    ),
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
    ],
  )

export const slice = defineSlice({
  fields: {},
  init: {},
  messages: [],
  handlers: () => ({}),
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/empty

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/empty.ts111 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

type Child = Html | string

import { cn } from '@/lib/utils'

export const emptyClass =
  'gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance'

export const emptyHeaderClass = 'gap-2 flex max-w-sm flex-col items-center'

export const emptyMediaVariantKeys = ['default', 'icon'] as const
export type EmptyMediaVariant = (typeof emptyMediaVariantKeys)[number]

export const emptyMediaClass =
  'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0'

export const emptyMediaVariants: Record<EmptyMediaVariant, string> = {
  default: 'bg-transparent',
  icon: 'bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*=\'size-\'])]:size-4',
}

export const emptyTitleClass = 'text-sm font-medium tracking-tight font-heading'

export const emptyDescriptionClass =
  'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary'

export const emptyContentClass =
  'gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance'

type StyleConfig = Readonly<{ className?: string }>

type EmptyMediaConfig = Readonly<{ variant?: EmptyMediaVariant; className?: string }>

const emptyContainer = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div([h.Class(cn(emptyClass, config.className)), h.DataAttribute('slot', 'empty')], children)

const emptyHeader = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.Class(cn(emptyHeaderClass, config.className)), h.DataAttribute('slot', 'empty-header')],
    children,
  )

const emptyMedia = <M>(
  config: EmptyMediaConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(
        cn(emptyMediaClass, emptyMediaVariants[config.variant ?? 'default'], config.className),
      ),
      h.DataAttribute('slot', 'empty-icon'),
      h.DataAttribute('variant', config.variant ?? 'default'),
    ],
    children,
  )

const emptyTitle = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.Class(cn(emptyTitleClass, config.className)), h.DataAttribute('slot', 'empty-title')],
    children,
  )

const emptyDescription = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(emptyDescriptionClass, config.className)),
      h.DataAttribute('slot', 'empty-description'),
    ],
    children,
  )

const emptyContent = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.Class(cn(emptyContentClass, config.className)), h.DataAttribute('slot', 'empty-content')],
    children,
  )

/** Styled empty state — `Empty.header`, `Empty.media`, `Empty.title`,
 *  `Empty.description`, `Empty.content` sub-builders. Mirrors the shadcn v4
 *  `empty.tsx`. */
export const Empty = Object.assign(emptyContainer, {
  header: emptyHeader,
  media: emptyMedia,
  title: emptyTitle,
  description: emptyDescription,
  content: emptyContent,
})