Empty States
blocklayoutGallery of six zero-data cards — no data, no search results, first-use, error recovery, no access, and sync complete — each with a matching isometric illustration, title, copy, and action pair. `variant` picks a single card, `size` compacts it, `layout` switches grid vs list. Override title, description, image, labels, hrefs, and callbacks. Pass `image=""` to hide the art.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/empty-states.json$npx shadcn@latest add https://uipkge.dev/r/lit/empty-states.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/empty-states.json$bunx shadcn@latest add https://uipkge.dev/r/lit/empty-states.jsonManifest:
https://uipkge.dev/r/lit/empty-states.jsonInstalls to:src/uip/blocks/empty-states/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | EmptyStatesVariant | 'all' | all | optional |
size | 'sm' | 'default' | default | optional |
layout | 'grid' | 'list' | grid | optional |
heading | string | — | optional |
description | string | — | optional |
image | string | — | optional |
image-alt | string | — | optional |
primary-label | string | — | optional |
secondary-label | string | — | optional |
primary-href | string | — | optional |
secondary-href | string | — | optional |
show-primary | boolean | true | optional |
show-secondary | boolean | true | optional |
primary-disabled | boolean | false | optional |
secondary-disabled | boolean | false | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
CardCopyinterface CardCopy {
image: string
heading: string
description: string
primaryLabel: string
secondaryLabel: string
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-empty-states': UipEmptyStates
}
}Files installed (1)
src/uip/blocks/empty-states/empty-states.ts8.7 kB
import { LitElement, css, html, nothing } from 'lit' import { RotateCcw } from 'lucide' import { cn } from '../../lib/utils' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect import: registers <uip-button> for standalone use (consumers // who install only this block still get a working button). import '../../components/button/button' export type EmptyStatesVariant = | 'no-data' | 'no-search-results' | 'first-use' | 'error-recovery' | 'no-access' | 'sync-complete' interface CardCopy { image: string heading: string description: string primaryLabel: string secondaryLabel: string } const DEFAULTS: Record<EmptyStatesVariant, CardCopy> = { 'no-data': { image: '/illustrations/empty-no-data.jpg', heading: 'Nothing here yet', description: 'Your workspace is empty. Create your first project or import existing data to get started.', primaryLabel: 'Create', secondaryLabel: 'Import data', }, 'no-search-results': { image: '/illustrations/empty-no-search.jpg', heading: 'No matches', description: 'Nothing matched your current filters. Try different keywords or broaden the search.', primaryLabel: 'Clear filters', secondaryLabel: 'View all', }, 'first-use': { image: '/illustrations/empty-first-use.jpg', heading: 'Welcome to Acme', description: 'This is your new dashboard. Set up your workspace and invite the team to start collaborating.', primaryLabel: 'Get started', secondaryLabel: 'Take a tour', }, 'error-recovery': { image: '/illustrations/empty-error-recovery.jpg', heading: 'Something broke', description: "We couldn't load your data. Your work is safe — check the connection and try again.", primaryLabel: 'Retry', secondaryLabel: 'Contact support', }, 'no-access': { image: '/illustrations/empty-no-access.jpg', heading: 'Ask your admin', description: "You don't have access to this workspace yet. Request permission and we'll notify you once it's granted.", primaryLabel: 'Request access', secondaryLabel: 'Switch account', }, 'sync-complete': { image: '/illustrations/empty-sync-complete.jpg', heading: 'Everything synced', description: 'All changes were uploaded a few seconds ago. We will keep syncing in the background.', primaryLabel: '', secondaryLabel: 'View activity', }, } const VARIANTS: EmptyStatesVariant[] = [ 'no-data', 'no-search-results', 'first-use', 'error-recovery', 'no-access', 'sync-complete', ] const falseConverter = { fromAttribute: (v: string | null) => v !== null && v !== 'false' } /** * <uip-empty-states> — the registry EmptyStates block as a web component. * * Class strings are React's EmptyStates verbatim. `heading` is React's `title` * (`title` is taken by HTMLElement). `onPrimary` / `onSecondary` become * `primary` / `secondary` CustomEvents (bubbles, composed). * * Deviation: React's `ResponsePanel` fallback (shown when the resolved art is * `undefined`) is unreachable — every variant has a default image, so the art * is only ever a string or `''`. Pass `image=""` to hide it. */ export class UipEmptyStates extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { variant: {}, size: {}, layout: { reflect: true }, heading: {}, description: {}, image: {}, imageAlt: { attribute: 'image-alt' }, primaryLabel: { attribute: 'primary-label' }, secondaryLabel: { attribute: 'secondary-label' }, primaryHref: { attribute: 'primary-href' }, secondaryHref: { attribute: 'secondary-href' }, showPrimary: { type: Boolean, attribute: 'show-primary', converter: falseConverter }, showSecondary: { type: Boolean, attribute: 'show-secondary', converter: falseConverter }, primaryDisabled: { type: Boolean, attribute: 'primary-disabled' }, secondaryDisabled: { type: Boolean, attribute: 'secondary-disabled' }, } variant: EmptyStatesVariant | 'all' = 'all' size: 'sm' | 'default' = 'default' layout: 'grid' | 'list' = 'grid' heading?: string description?: string image?: string imageAlt = '' primaryLabel?: string secondaryLabel?: string primaryHref?: string secondaryHref?: string showPrimary = true showSecondary = true primaryDisabled = false secondaryDisabled = false constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'empty-states') this.setAttribute('data-layout', this.layout) } willUpdate() { this.setAttribute('data-layout', this.layout) } private emit(name: 'primary' | 'secondary') { this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true })) } private card(v: EmptyStatesVariant) { const d = DEFAULTS[v] const single = this.variant === v const img = single && this.image !== undefined ? this.image : d.image const titleText = single && this.heading !== undefined ? this.heading : d.heading const descText = single && this.description !== undefined ? this.description : d.description const primaryText = single && this.primaryLabel !== undefined ? this.primaryLabel : d.primaryLabel const secondaryText = single && this.secondaryLabel !== undefined ? this.secondaryLabel : d.secondaryLabel const sm = this.size === 'sm' const actionSize = sm ? 'xs' : 'sm' return html` <article class=${cn( 'border-border bg-card overflow-hidden rounded-xl border text-left', sm && 'rounded-lg', this.layout === 'list' && 'w-full max-w-md', )} data-slot="empty-states-card" data-variant=${v} role=${v === 'error-recovery' ? 'alert' : 'status'} > ${img ? html`<img src=${img} alt=${this.imageAlt} class=${cn('w-full object-cover', sm ? 'aspect-[16/9]' : 'aspect-[16/10]')} />` : nothing} <div class=${sm ? 'p-5' : 'p-6'}> <h3 class="text-sm font-medium">${titleText}</h3> <p class="text-muted-foreground mt-1 max-w-xs text-sm">${descText}</p> ${this.showPrimary || this.showSecondary ? html`<div class=${cn('flex flex-wrap items-center gap-2', sm ? 'mt-4' : 'mt-5')}> ${v === 'sync-complete' ? html`${this.showSecondary ? html`<uip-button size=${actionSize} variant="ghost" ?disabled=${this.secondaryDisabled} href=${this.secondaryHref ?? nothing} @click=${() => this.emit('secondary')} > ${secondaryText} </uip-button>` : nothing}` : html` ${this.showPrimary ? html`<uip-button size=${actionSize} ?disabled=${this.primaryDisabled} href=${this.primaryHref ?? nothing} @click=${() => this.emit('primary')} > ${v === 'error-recovery' ? icon(RotateCcw, 'rotate-ccw') : nothing}${primaryText} </uip-button>` : nothing} ${this.showSecondary ? html`<uip-button size=${actionSize} variant="ghost" ?disabled=${this.secondaryDisabled} href=${this.secondaryHref ?? nothing} @click=${() => this.emit('secondary')} > ${secondaryText} </uip-button>` : nothing} `} </div>` : nothing} </div> </article> ` } render() { const shown = this.variant === 'all' ? VARIANTS : [this.variant as EmptyStatesVariant] return html` <div data-slot="empty-states" data-layout=${this.layout} class=${cn( this.layout === 'list' ? 'flex flex-col items-center gap-4' : 'grid gap-4 sm:grid-cols-2 xl:grid-cols-3', )} > ${shown.map((v) => this.card(v))} </div> ` } } customElements.get('uip-empty-states') || customElements.define('uip-empty-states', UipEmptyStates) declare global { interface HTMLElementTagNameMap { 'uip-empty-states': UipEmptyStates } }
Raw manifest:https://uipkge.dev/r/lit/empty-states.json