Vanilla UI Kit EmptyState v1.0.0 · toast →

Empty states, one file.

A friendly placeholder for nothing at all — four line-style illustrations, an accent action, and your page's theme, automatically.

The four icons

inbox · search · error · folder — all drawn in the family's 1.5px-stroke style. size: 'sm' here.

EmptyState.render(el, { icon: 'search', size: 'sm',
  title: 'No results', description: '…' })

With actions

An accent button plus a quiet link-button.

EmptyState.render('#actions', {
  icon: 'folder',
  title: 'No projects yet',
  description: 'Create your first project…',
  action: { label: 'New project', onClick: … },
  secondaryAction: { label: 'Learn more', onClick: … }
})

Search, no results

Type something unfindable — the empty state appears with the query in its description and a “Clear search” action. Try “zzz”.

    handle = EmptyState.render('#no-results', {
      icon: 'search', size: 'sm',
      title: 'No matches',
      description: 'Nothing matches “' + q + '”.',
      action: { label: 'Clear search', onClick: clear }
    })
    handle.update({ description: … })   // as the query changes
    handle.remove()                     // when results return

    Declarative

    No JS at all — data attributes plus an adopted action button that keeps its own onclick.

    <div data-ves data-ves-icon="error"
         data-ves-title="Something went wrong"
         data-ves-description="We could not load this view.">
      <button data-ves-action onclick="retry()">Retry</button>
    </div>

    Live update

    update() re-renders the same handle in place.

    handle.update({ icon: 'inbox',
      title: 'Inbox zero', description: '…' })

    API reference

    The complete EmptyState API — the render/constructor duality, every option, the handle, statics, declarative attributes, theming and accessibility.

    Render & constructor

    EmptyState.render(target, options?)  // → handle: { el, update(opts), remove() }
    new EmptyState(target, options?)     // alias of render — same handle back
    EmptyState.create(target, options?)  // also an alias

    All three forms are the same call: new EmptyState(…) simply returns EmptyState.render(…)'s plain handle (there is no class instance), so the family create/get contract holds. target is a selector string or an element; the block is appended into it. Re-rendering onto the same target replaces the previous block (the old handle is removed first). A missing target throws; without a DOM (SSR) every call returns an inert handle.

    const empty = EmptyState.render('#list', {
      icon: 'inbox',
      title: 'No messages yet',
      description: 'Anything sent to you will land here.',
      action: { label: 'Compose', onClick: () => compose() }
    })

    Options

    Every key of EmptyState.defaults, with its default value.

    NameTypeDefaultDescription
    iconstring'inbox' One of the built-ins — 'inbox' | 'search' | 'error' | 'folder' — or a TRUSTED inline-SVG string (anything starting with '<' is injected verbatim; never pass user input). Unknown names fall back to 'inbox'.
    titlestring'' TEXT — always rendered with textContent. Omitted from the DOM when empty.
    descriptionstring'' TEXT by default; html: true opts in to markup. Omitted when empty.
    htmlbooleanfalse Applies to description only — render it as HTML instead of text.
    actionobject | Element | nullnull { label, onClick(handle) } → the solid accent button. Declarative init may pass an adopted element instead (see below).
    secondaryActionobject | nullnull { label, onClick(handle) } → the quiet link-button.
    sizestring'md' 'sm' | 'md'. 'sm' tightens paddings and shrinks the illustration for cards, table bodies and sidebars.
    themestring'auto' 'auto' | 'light' | 'dark'. Auto re-resolves live against the page theme.
    stylesbooleantrue false = headless: no CSS is injected — style the .ves-* markup yourself.

    Handle

    Every render returns the same plain handle shape:

    MemberDescription
    el The root .ves element (or null for the inert SSR handle).
    update(opts) Merge opts over the current options and re-render the block's content in place — same root element, no flicker. Chainable.
    remove() Take the block back out of the DOM. An adopted declarative action button is returned to the host element with its listeners intact. Chainable.

    Action callbacks receive the handle, so an action can update its own block:

    action: { label: 'New project',
      onClick: (h) => h.update({ title: 'Project created!' }) }

    Statics & helpers

    StaticDescription
    EmptyState.render(target, opts) The canonical entry point → handle.
    EmptyState.create(target, opts) Alias of render.
    EmptyState.get(el) The handle bound to a target element — works with the original target and with handle.el. null when none.
    EmptyState.autoInit(root?) Initialize every [data-ves] under root (default: document); returns the created handles. Runs automatically on DOMContentLoaded.
    EmptyState.defaults The live defaults object — mutate before rendering (e.g. EmptyState.defaults.styles = false).
    EmptyState.css The full stylesheet as a string (rendered with the current salt) — a starting point for headless styling.
    EmptyState.salt CSS isolation token, default 'vc1'. Set your own token or false before the first render.
    EmptyState.version Version string, '1.0.0'.

    Declarative init

    Add data-ves to a container — blocks are built on DOMContentLoaded (or call EmptyState.autoInit(root) after inserting markup). A child <button data-ves-action> is adopted as the accent button: its own listeners (e.g. an inline onclick) stay attached, it is given type="button" if it has no type, and remove() gives it back to the host.

    <div data-ves data-ves-icon="search" data-ves-title="No results"
         data-ves-description="Try a different search term.">
      <button data-ves-action onclick="clearFilters()">Clear filters</button>
    </div>
    AttributeMaps toNotes
    data-ves—Marks the container.
    data-ves-iconiconBuilt-in name or trusted SVG string.
    data-ves-title / data-ves-descriptiontitle / descriptionPlain text.
    data-ves-sizesizesm | md.
    data-themethemeNote: unprefixed (not data-ves-theme).
    data-stylesstylesUnprefixed boolean; "false" and "0" = false.
    child [data-ves-action]actionThe element is adopted as the accent button, listeners intact.

    Theming

    Auto light/dark with the family's resolution order: <html data-theme> / data-bs-theme / .dark class → prefers-color-scheme, re-resolved live. Pin one block with theme: 'dark'. All colors are CSS custom properties on .ves:

    .ves {
      --ves-accent: #b45309;   /* action button, quiet action, focus rings */
      --ves-text: …; --ves-muted: …; --ves-faint: …;
      --ves-circle: …;         /* the illustration circle */
      --ves-on-accent: …;      /* text on the accent button */
      --ves-radius: 10px; --ves-font: …;
    }

    With the VC core loaded, VC.config({ accent: '#b45309' }) themes empty states and every other family component in one call. Blocks render as class="ves ves-md vc1"; structural rules ship salted so host design systems can't override them, while the unsalted --ves-* variable definitions keep page overrides working. Change the token with EmptyState.salt = 'acme' (or false) before the first render.

    Headless: pass styles: false (per render, or via EmptyState.defaults) and no CSS is injected — you keep the behavior (rendering, adoption, theme tracking) and style this markup contract yourself (EmptyState.css is the reference stylesheet as a string):

    .ves.ves-md[data-theme="dark"]     ← .ves-sm for size: 'sm'
      .ves-art                         ← illustration circle + inline SVG
      .ves-title                       ← only when `title` given
      .ves-desc                        ← only when `description` given
      .ves-actions                     ← only when any action given
        button.ves-action              ← accent button (or adopted button)
        button.ves-action-quiet        ← secondaryAction

    Accessibility

    Headless mode

    With styles: false EmptyState injects no CSS at all — the full behavior (rendering, adoption, update()/remove()), the accessibility contract and the stable .ves-* class hooks all remain; you bring the stylesheet. The demos below each run in their own <iframe>: the styled examples above have already injected the component stylesheet into this page, so only a separate, clean document can show what headless truly looks like.

    Unstyled (styles: false)

    The raw markup contract — aria-hidden illustration, title, description and two real <button>s. Zero CSS was injected; the actions still fire.

    EmptyState.render('#demo', {
      icon: 'inbox',
      title: 'No messages yet',
      description: 'Anything sent to you will land here.',
      action: { label: 'Compose', onClick: function () { /* … */ } },
      styles: false   // ← zero CSS injected
    })

    Bring your own design (Tailwind)

    The same class hooks mapped to a deliberately different design with Tailwind utilities — a tilted gradient illustration tile, pill actions, its own palette. Nothing loads from the CDN until you click.

    <style type="text/tailwindcss">
      .ves          { @apply mx-auto flex max-w-sm flex-col items-center
                      px-6 py-10 text-center; }
      .ves-art      { @apply mb-5 grid h-24 w-24 rotate-3 place-items-center
                      rounded-3xl bg-gradient-to-br from-amber-200 to-orange-300
                      text-orange-700 shadow-lg shadow-orange-200; }
      .ves-art svg  { @apply h-10 w-10; }
      .ves-title    { @apply text-lg font-extrabold tracking-tight
                      text-slate-800; }
      .ves-desc     { @apply mt-1.5 text-sm leading-relaxed text-slate-500; }
      .ves-actions  { @apply mt-5 flex items-center gap-3; }
      .ves-action   { @apply cursor-pointer rounded-full border-0 bg-orange-500
                      px-5 py-2.5 text-sm font-bold text-white shadow-md
                      shadow-orange-200 hover:bg-orange-600; }
      .ves-action-quiet { @apply cursor-pointer rounded-full border-0
                      bg-transparent px-4 py-2.5 text-sm font-semibold
                      text-slate-500 hover:text-slate-700; }
    </style>