A friendly placeholder for nothing at all — four line-style illustrations, an accent action, and your page's theme, automatically.
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: '…' })
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: … }
})
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
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>
update() re-renders the same handle in place.
handle.update({ icon: 'inbox',
title: 'Inbox zero', description: '…' })
The complete EmptyState API — the render/constructor duality,
every option, the handle, statics, declarative attributes, theming and
accessibility.
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() }
})
Every key of EmptyState.defaults, with its default value.
| Name | Type | Default | Description |
|---|---|---|---|
icon | string | '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'. |
title | string | '' |
TEXT — always rendered with textContent. Omitted from the DOM when empty. |
description | string | '' |
TEXT by default; html: true opts in to markup. Omitted when empty. |
html | boolean | false |
Applies to description only — render it as HTML instead of text. |
action | object | Element | null | null |
{ label, onClick(handle) } → the solid accent button. Declarative init may pass an adopted element instead (see below). |
secondaryAction | object | null | null |
{ label, onClick(handle) } → the quiet link-button. |
size | string | 'md' |
'sm' | 'md'. 'sm' tightens paddings and shrinks the illustration for cards, table bodies and sidebars. |
theme | string | 'auto' |
'auto' | 'light' | 'dark'. Auto re-resolves live against the page theme. |
styles | boolean | true |
false = headless: no CSS is injected — style the .ves-* markup yourself. |
Every render returns the same plain handle shape:
| Member | Description |
|---|---|
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!' }) }
| Static | Description |
|---|---|
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'. |
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>
| Attribute | Maps to | Notes |
|---|---|---|
data-ves | — | Marks the container. |
data-ves-icon | icon | Built-in name or trusted SVG string. |
data-ves-title / data-ves-description | title / description | Plain text. |
data-ves-size | size | sm | md. |
data-theme | theme | Note: unprefixed (not data-ves-theme). |
data-styles | styles | Unprefixed boolean; "false" and "0" = false. |
child [data-ves-action] | action | The element is adopted as the accent button, listeners intact. |
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
aria-hidden — the title carries the
meaning, so give every block a title.action renders as a solid accent <button
type="button">, secondaryAction as a quiet
link-button; both are real buttons (native keyboard support) with family
focus-visible rings, and they respect reduced motion.title is always text; description is text
unless you explicitly opt in with html: true. Custom SVG icon
strings are injected verbatim — pass only markup you trust, never user
input.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.
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
})
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>