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 HTTP response panel, title, copy, and action pair.
Installation
$pnpm dlx uipkge-ng@latest add empty-states$npx uipkge-ng@latest add empty-states$yarn dlx uipkge-ng@latest add empty-states$bunx uipkge-ng@latest add empty-statesManifest:
https://uipkge.dev/r/angular/empty-states.jsonInstalls to:src/app/components/blocks/empty-states/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
EmptyStateCardinterface EmptyStateCard {
variant: EmptyStateVariant
image: string
request: string
status: string
detail: string
tone?: 'destructive' | 'warning'
title: string
description: string
primaryLabel: string
secondaryLabel: string
role: 'status' | 'alert'
primaryOnly: boolean
secondaryOnly: boolean
}npm dependencies
Includes
Files installed (2)
src/app/components/blocks/empty-states/empty-states.component.ts10.2 kB
// Gallery of six zero-data cards: no data, no search results, first-use, error recovery, // no access, and sync complete — each with a matching HTTP response panel, title, copy, and // action pair. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core' import { RotateCcw, LucideAngularModule } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiButtonComponent } from '@/ui/button/button.component' export type EmptyStateVariant = 'no-data' | 'no-search-results' | 'first-use' | 'error-recovery' | 'no-access' | 'sync-complete' interface EmptyStateCard { variant: EmptyStateVariant image: string request: string status: string detail: string tone?: 'destructive' | 'warning' title: string description: string primaryLabel: string secondaryLabel: string role: 'status' | 'alert' primaryOnly: boolean secondaryOnly: boolean } const cards: EmptyStateCard[] = [ { variant: 'no-data', image: '/illustrations/empty-no-data.jpg', request: 'GET /v1/projects', status: '200 OK', detail: '{ "data": [], "total": 0 }', title: 'Nothing here yet', description: 'Your workspace is empty. Create your first project or import existing data to get started.', primaryLabel: 'Create', secondaryLabel: 'Import data', role: 'status', primaryOnly: false, secondaryOnly: false, }, { variant: 'no-search-results', image: '/illustrations/empty-no-search.jpg', request: 'GET /search?q=northwind', status: '200 OK', detail: '{ "hits": 0 }', title: 'No matches', description: 'Nothing matched your current filters. Try different keywords or broaden the search.', primaryLabel: 'Clear filters', secondaryLabel: 'View all', role: 'status', primaryOnly: false, secondaryOnly: false, }, { variant: 'first-use', image: '/illustrations/empty-first-use.jpg', request: 'GET /v1/workspace', status: '200 OK', detail: '{ "onboarded": false }', title: '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', role: 'status', primaryOnly: false, secondaryOnly: false, }, { variant: 'error-recovery', image: '/illustrations/empty-error-recovery.jpg', request: 'GET /api/projects', status: '500 Internal Server Error', detail: 'error: upstream_timeout', tone: 'destructive', title: 'Something broke', description: "We couldn't load your data. Your work is safe — check the connection and try again.", primaryLabel: 'Retry', secondaryLabel: 'Contact support', role: 'alert', primaryOnly: false, secondaryOnly: false, }, { variant: 'no-access', image: '/illustrations/empty-no-access.jpg', request: 'GET /settings/billing', status: '403 Forbidden', detail: 'scope billing:read — missing', tone: 'warning', title: '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', role: 'status', primaryOnly: false, secondaryOnly: false, }, { variant: 'sync-complete', image: '/illustrations/empty-sync-complete.jpg', request: 'POST /sync', status: '200 OK', detail: '{ "synced": 128, "lag_ms": 40 }', title: 'Everything synced', description: 'All changes were uploaded a few seconds ago. We will keep syncing in the background.', primaryLabel: 'View activity', secondaryLabel: 'View activity', role: 'status', primaryOnly: false, secondaryOnly: true, }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-empty-states, [ui-empty-states]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [LucideAngularModule, UiButtonComponent], template: ` <div data-slot="empty-states" [attr.data-layout]="layout" [class]="rootClass"> @for (card of visibleCards(); track card.variant) { <article [class]="cardClass" data-slot="empty-states-card" [attr.data-variant]="card.variant" [attr.role]="card.role" > @if (hasArt(card)) { <img [src]="art(card)" [alt]="imageAlt" [class]="imgClass" /> } @else if (art(card) === undefined) { <div class="border-border bg-muted/40 border-b font-mono" aria-hidden="true"> <div class="border-border text-muted-foreground truncate border-b px-3 py-2 text-xs"> {{ card.request }} </div> <div class="space-y-1 p-3 text-xs leading-relaxed"> <p> <span class="text-muted-foreground">HTTP/1.1</span> @if (card.tone === 'destructive') { <span class="text-destructive">{{ card.status }}</span> } @else if (card.tone === 'warning') { <span class="text-warning">{{ card.status }}</span> } @else { {{ card.status }} } </p> <p class="text-muted-foreground">{{ card.detail }}</p> </div> </div> } <div [class]="bodyClass"> <h3 class="text-sm font-medium">{{ pickTitle(card) }}</h3> <p class="text-muted-foreground mt-1 max-w-xs text-sm">{{ pickDescription(card) }}</p> @if (showPrimary || showSecondary) { <div [class]="actionsClass"> @if (showPrimary && !card.secondaryOnly) { @if (primaryHref) { <a ui-button [href]="primaryHref" [size]="actionSize" (click)="primary.emit()"> @if (card.variant === 'error-recovery') { <lucide-icon [img]="RotateCcw" /> } {{ pickPrimary(card) }} </a> } @else { <button ui-button [size]="actionSize" [disabled]="primaryDisabled" (click)="primary.emit()"> @if (card.variant === 'error-recovery') { <lucide-icon [img]="RotateCcw" /> } {{ pickPrimary(card) }} </button> } } @if (showSecondary && !card.primaryOnly) { @if (secondaryHref) { <a ui-button [size]="actionSize" variant="ghost" [href]="secondaryHref" (click)="secondary.emit()" >{{ pickSecondary(card) }}</a > } @else { <button ui-button [size]="actionSize" variant="ghost" [disabled]="secondaryDisabled" (click)="secondary.emit()" > {{ pickSecondary(card) }} </button> } } </div> } </div> </article> } </div> `, }) export class UiEmptyStatesComponent { protected readonly RotateCcw = RotateCcw /** `'all'` renders the full gallery; a single variant renders just that card. */ @Input() variant: EmptyStateVariant | 'all' = 'all' @Input() size: 'sm' | 'default' = 'default' @Input() layout: 'grid' | 'list' = 'grid' @Input('class') className?: string @Input() title?: string @Input() description?: string /** Replaces the response panel on a single variant. Pass `''` to hide art. */ @Input() image?: string @Input() imageAlt = '' @Input() primaryLabel?: string @Input() secondaryLabel?: string @Input() primaryHref?: string @Input() secondaryHref?: string @Input() showPrimary = true @Input() showSecondary = true @Input() primaryDisabled = false @Input() secondaryDisabled = false @Output() primary = new EventEmitter<void>() @Output() secondary = new EventEmitter<void>() protected readonly allCards = cards visibleCards(): EmptyStateCard[] { return this.variant === 'all' ? cards : cards.filter((c) => c.variant === this.variant) } private isSingle(v: EmptyStateVariant): boolean { return this.variant === v } pickTitle(card: EmptyStateCard): string { return this.isSingle(card.variant) && this.title !== undefined ? this.title : card.title } pickDescription(card: EmptyStateCard): string { return this.isSingle(card.variant) && this.description !== undefined ? this.description : card.description } pickPrimary(card: EmptyStateCard): string { return this.isSingle(card.variant) && this.primaryLabel !== undefined ? this.primaryLabel : card.primaryLabel } pickSecondary(card: EmptyStateCard): string { return this.isSingle(card.variant) && this.secondaryLabel !== undefined ? this.secondaryLabel : card.secondaryLabel } art(card: EmptyStateCard): string | undefined { if (this.isSingle(card.variant)) return this.image === undefined ? card.image : this.image return card.image } hasArt(card: EmptyStateCard): boolean { const img = this.art(card) return img !== undefined && img !== '' } get rootClass(): string { return cn( this.layout === 'list' ? 'flex flex-col items-center gap-4' : 'grid gap-4 sm:grid-cols-2 xl:grid-cols-3', this.className, ) } get cardClass(): string { return cn( 'border-border bg-card overflow-hidden rounded-xl border text-left', this.size === 'sm' && 'rounded-lg', this.layout === 'list' && 'w-full max-w-md', ) } get imgClass(): string { return cn('w-full object-cover', this.size === 'sm' ? 'aspect-[16/9]' : 'aspect-[16/10]') } get bodyClass(): string { return this.size === 'sm' ? 'p-5' : 'p-6' } get actionsClass(): string { return cn('flex flex-wrap items-center gap-2', this.size === 'sm' ? 'mt-4' : 'mt-5') } get actionSize(): 'xs' | 'sm' { return this.size === 'sm' ? 'xs' : 'sm' } }src/app/components/blocks/empty-states/index.ts0.1 kB
export { UiEmptyStatesComponent, type EmptyStateVariant } from './empty-states.component'
Raw manifest:https://uipkge.dev/r/angular/empty-states.json