Menu

Change language

GitHub repo

Empty States

blocklayout

Gallery 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

$npx shadcn@latest add https://uipkge.dev/r/lit/empty-states.json
Manifest:https://uipkge.dev/r/lit/empty-states.jsonInstalls to:src/uip/blocks/empty-states/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
variantEmptyStatesVariant | 'all'alloptional
size'sm' | 'default'defaultoptional
layout'grid' | 'list'gridoptional
headingstring—optional
descriptionstring—optional
imagestring—optional
image-altstring—optional
primary-labelstring—optional
secondary-labelstring—optional
primary-hrefstring—optional
secondary-hrefstring—optional
show-primarybooleantrueoptional
show-secondarybooleantrueoptional
primary-disabledbooleanfalseoptional
secondary-disabledbooleanfalseoptional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

CardCopy
interface CardCopy {
  image: string
  heading: string
  description: string
  primaryLabel: string
  secondaryLabel: string
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-empty-states': UipEmptyStates
  }
}

npm dependencies

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