Empty state block
An empty Dashboards page: SvEmptyState with an icon, title, one sentence and a New dashboard SvButton that opens an inline SvTextInput form. Creating adds the dashboard to the list; deleting the last one brings the empty state back, with Undo in the toast.
A live, editable Svelte 5 component example from the SvGrid gallery (Blocks). Read the SvGrid UI component docs for the full API.
About this example
An empty Dashboards page built from the SvGrid UI kit in Svelte 5. SvEmptyState shows an icon, a one-line title, one sentence and a New dashboard SvButton; the button opens an inline SvTextInput form that validates the name and creates the first dashboard, and the list then shows it. Deleting the last dashboard brings the empty state back, with an Undo action in the toast.
Empty state block - the blank Dashboards page of an analytics workspace. SvEmptyState carries the icon, title, one sentence and the primary action; the action opens an inline SvTextInput form that creates the first dashboard, the list then shows it, and deleting the last one brings the empty state back (with an Undo toast). SvButton, SvBadge, SvToaster. Pure UI-kit, --sg-* themed.
Headless: createDashboardList() below owns the list, the create form with its validation and delete with undo, with no markup. The component under it is one styled render of that core; import createDashboardList from this file (or npx @svgrid/ui add empty-states-simple --headless for the core alone) to draw your own.
Imports, features and API used
Imports: @svgrid/grid
Frequently asked questions
How does the block decide between the empty state and the list?
It renders SvEmptyState when the dashboards array is empty and the create form is closed, and the list otherwise. Load the array from your API or a SvelteKit load function and the same if-branch picks the right view.
How is the name validated?
The core's create() trims the value and sets an error when it is blank or matches an existing dashboard name; the SvTextInput shows it through its error prop and invalid turns the field red. The required prop also marks the field for screen readers.
How do I save the new dashboard to a server?
Pass an onCreate option to createDashboardList that POSTs the name. The core adds the dashboard when it resolves, keeps the form open when it rejects, and holds the saving flag around it so the Create button shows its loading spinner.
Related documentation
Related articles
- Empty, Loading, and Error States for a Svelte Data Grid - How to handle the three non-data states in a Svelte grid - empty (with two subtypes), skeleton loading that does not flash, and recoverable errors that keep existing rows visible.
- $state Deep Dive for Data-Heavy Svelte Apps - A working engineer's guide to $state, $state.raw, and $state.snapshot - when each one is the right tool, and the exact failure modes when you pick the wrong one.
- Runes vs Stores in Svelte 5 - When to Use Which - Svelte 5 runes and stores are not competitors - they solve different problems. Here is a concrete breakdown of when $state wins, when writable still earns its place, and how to mix both without subtle bugs.
Source code (534-block-empty-states-simple.svelte)
<script lang="ts" module>
/**
* Empty state block - the blank Dashboards page of an analytics workspace.
* SvEmptyState carries the icon, title, one sentence and the primary action;
* the action opens an inline SvTextInput form that creates the first
* dashboard, the list then shows it, and deleting the last one brings the
* empty state back (with an Undo toast). SvButton, SvBadge, SvToaster.
* Pure UI-kit, --sg-* themed.
*
* Headless: createDashboardList() below owns the list, the create form with
* its validation and delete with undo, with no markup. The component under
* it is one styled render of that core; import createDashboardList from
* this file (or `npx @svgrid/ui add empty-states-simple --headless` for the
* core alone) to draw your own.
*/
export type Dashboard = { id: number; name: string; created: string }
export type DashboardListOptions = {
/** Dashboards already saved. Empty by default, so the empty state shows. */
initial?: Dashboard[]
/** Save the new dashboard; throw (or reject) to keep the form open. */
onCreate?: (name: string) => Promise<void> | void
/** Called after a delete with an undo that puts it back in place. */
onRemove?: (dashboard: Dashboard, undo: () => void) => void
}
export function createDashboardList(opts: DashboardListOptions = {}) {
const initial = opts.initial ?? []
let dashboards = $state<Dashboard[]>([...initial])
let creating = $state(false)
let name = $state('')
let error = $state<string | undefined>()
let saving = $state(false)
let nextId = Math.max(0, ...initial.map((d) => d.id)) + 1
function openForm() {
creating = true
name = ''
error = undefined
}
async function create(ev?: Event) {
ev?.preventDefault()
const value = name.trim()
if (!value) { error = 'Give the dashboard a name'; return }
if (dashboards.some((d) => d.name.toLowerCase() === value.toLowerCase())) { error = 'A dashboard with this name exists'; return }
error = undefined
saving = true
try {
await opts.onCreate?.(value)
dashboards = [...dashboards, { id: nextId++, name: value, created: 'Created just now' }]
creating = false
} finally {
saving = false
}
}
function remove(d: Dashboard) {
const index = dashboards.findIndex((x) => x.id === d.id)
dashboards = dashboards.filter((x) => x.id !== d.id)
const undo = () => { dashboards = [...dashboards.slice(0, index), d, ...dashboards.slice(index)] }
opts.onRemove?.(d, undo)
}
return {
get dashboards() { return dashboards },
/** True when there is nothing to list and the form is closed. */
get isEmpty() { return dashboards.length === 0 && !creating },
get creating() { return creating },
get name() { return name },
set name(v: string) { name = v },
get error() { return error },
get saving() { return saving },
openForm,
closeForm: () => { creating = false },
create,
remove,
/** Spread on your create <form>. */
formProps: () => ({ onsubmit: create }),
}
}
</script>
<script lang="ts">
import { SvEmptyState, SvTextInput, SvButton, SvBadge, SvToaster, toast } from '@svgrid/grid'
const boards = createDashboardList({
// Replace the timer with your create request.
onCreate: (value) =>
new Promise<void>((res) => setTimeout(res, 700)).then(() => {
toast.success(`"${value}" created`)
}),
onRemove: (d, undo) => toast(`"${d.name}" deleted`, { action: { label: 'Undo', onClick: undo } }),
})
</script>
<section class="sv-boards" aria-labelledby="sv-boards-title">
<header class="sv-boards-bar">
<div>
<h2 id="sv-boards-title">Dashboards</h2>
<p class="sv-boards-muted">Acme Analytics workspace</p>
</div>
{#if boards.dashboards.length && !boards.creating}
<SvButton size="sm" onclick={boards.openForm}>New dashboard</SvButton>
{/if}
</header>
<div class="sv-boards-panel" class:sv-boards-has-items={boards.dashboards.length > 0}>
{#if boards.creating}
<form class="sv-boards-create" {...boards.formProps()}>
<SvTextInput label="Dashboard name" placeholder="e.g. Weekly revenue" bind:value={boards.name} error={boards.error} invalid={!!boards.error} required autofocus block />
<div class="sv-boards-create-actions">
<SvButton variant="ghost" onclick={boards.closeForm}>Cancel</SvButton>
<SvButton type="submit" loading={boards.saving}>Create dashboard</SvButton>
</div>
</form>
{/if}
{#if boards.isEmpty}
<SvEmptyState title="No dashboards yet" description="A dashboard collects the charts and tables your team checks every week.">
{#snippet icon()}
<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="8" height="10" rx="1.5" /><rect x="13" y="3" width="8" height="6" rx="1.5" />
<rect x="13" y="11" width="8" height="10" rx="1.5" /><rect x="3" y="15" width="8" height="6" rx="1.5" />
</svg>
{/snippet}
<SvButton onclick={boards.openForm}>New dashboard</SvButton>
</SvEmptyState>
{:else if boards.dashboards.length}
<ul class="sv-boards-list">
{#each boards.dashboards as d (d.id)}
<li>
<span class="sv-boards-tile" aria-hidden="true">{d.name.slice(0, 1).toUpperCase()}</span>
<div class="sv-boards-meta">
<strong>{d.name}</strong>
<span class="sv-boards-muted">{d.created}</span>
</div>
<SvBadge size="sm">Private</SvBadge>
<SvButton variant="ghost" size="sm" ariaLabel={`Delete ${d.name}`} onclick={() => boards.remove(d)}>Delete</SvButton>
</li>
{/each}
</ul>
{/if}
</div>
</section>
<SvToaster position="bottom-right" />
<style>
.sv-boards {
min-height: 460px; padding: 28px; box-sizing: border-box;
display: flex; flex-direction: column; gap: 18px;
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
font-family: var(--sg-font, inherit);
}
.sv-boards-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.sv-boards-bar h2 { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: -.01em; }
.sv-boards-muted { margin: 2px 0 0; font-size: 13px; color: var(--sg-muted, #64748b); }
.sv-boards-panel {
flex: 1; display: flex; flex-direction: column; justify-content: center;
border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 12px);
background: var(--sg-bg-subtle, #f8fafc); padding: 8px;
}
.sv-boards-create {
display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 420px; margin: 24px auto;
padding: 18px; box-sizing: border-box; border-radius: var(--sg-radius, 8px);
background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0);
}
.sv-boards-panel.sv-boards-has-items { flex: none; justify-content: flex-start; background: var(--sg-bg, #fff); padding: 0 4px; }
.sv-boards-create-actions { display: flex; justify-content: flex-end; gap: 8px; }
.sv-boards-list { list-style: none; margin: 0; padding: 0; align-self: stretch; flex: 1; }
.sv-boards-list li {
display: flex; align-items: center; gap: 12px; padding: 12px;
border-bottom: 1px solid var(--sg-border, #e2e8f0);
}
.sv-boards-list li:last-child { border-bottom: 0; }
.sv-boards-tile {
flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 9px;
font-weight: 700; color: var(--sg-fg, #0f172a);
background: color-mix(in srgb, var(--sg-muted, #64748b) 18%, transparent);
}
.sv-boards-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.sv-boards-meta strong { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sv-boards-meta .sv-boards-muted { margin: 0; font-size: 12.5px; }
@media (max-width: 560px) {
.sv-boards { padding: 18px 16px; }
.sv-boards-bar { flex-wrap: wrap; }
.sv-boards-list li :global(.sv-badge) { display: none; }
}
</style>More Blocks examples
- Login block - A two-column authentication page: a branded gradient panel beside a sign-in SvCard with social SvButtons, SvTextInput + SvPasswordInput (reveal), remember-me SvCheckBox, an "or" SvDivider and forgot-password link. Drops into any route as-is.
- Sign-up block - A centered account-creation card: two-up name fields, work email, a password with the live strength meter, a plan SvSegmented switch, terms SvCheckBox and a benefits rail. Submit fires a promise toast. Pure UI-kit.
- Two-factor / OTP block - The verification step of a sign-in flow: SvOtpInput (6 digits, auto-advance + onComplete), a resend cooldown timer, and the waiting -> verifying -> verified states. Enter 000000 to see the error path.
- Analytics dashboard block - A full overview page: a range SvSegmented + actions header, a KPI grid (SvStat + SvSparkline), a revenue bar panel, a recent-activity SvTimeline and a top-products list with SvProgress bars. No grid dependency.
- App shell block - A complete application layout: a SvNavPane sidebar with sections + badges, a top bar with SvBreadcrumb, search and an account SvMenu, and a routed content area. Nav swaps the page; the toggle collapses the rail to an icon rail.