Skip to content

Async

A zero-boilerplate wrapper that drives the right UI for every stage of an async data fetch. It manages aria-busy, aria-live, and role automatically so screen readers always stay informed.

status defaults to success, so default slotted content is visible unless you explicitly set another state.

Features

  • 5 statuses: idle, loading, empty, error, success
  • Default skeleton loading state — no setup needed
  • Built-in empty state with configurable label and description
  • Built-in error state with optional retry button
  • Fully slottable — replace any built-in view with your own content
  • Automatic ARIAaria-busy, aria-live, role="alert" managed for you

Source Code

View Source Code
ts
import { define, html, prop, when } from '@vielzeug/craft';
import { type ReadonlySignal, signal } from '@vielzeug/ripple';

import { reducedMotionMixin } from '../../styles';
import componentStyles from './async.css?inline';

export type AsyncStatus = 'idle' | 'loading' | 'empty' | 'error' | 'success';

export type SgAsyncEvents = {
  retry: void;
};

export type SgAsyncProps = {
  'empty-description'?: string;
  'empty-label'?: string;
  'error-description'?: string;
  'error-label'?: string;
  retryable?: boolean;
  status?: AsyncStatus;
};

/**
 * A container for handling asynchronous states (loading, empty, error, success).
 * Simplifies data fetching UI by providing consistent fallbacks.
 *
 * @element sg-async
 *
 * @attr {string} status - current state: 'idle' | 'loading' | 'empty' | 'error' | 'success' (default: 'success')
 * @attr {boolean} retryable - show retry button in error state (default: false)
 * @attr {string} empty-label - title for empty state (default: 'No content yet')
 * @attr {string} empty-description - optional text for empty state
 * @attr {string} error-label - title for error state (default: 'Something went wrong')
 * @attr {string} error-description - optional text for error state
 *
 * @fires retry - Emitted when the retry button is clicked (no detail payload)
 *
 * @slot - default content shown in 'success' state
 * @slot loading - custom loading UI (overrides default skeletons)
 * @slot empty - custom empty UI (overrides default icon/label)
 * @slot error - custom error UI (overrides default icon/label)
 *
 * @cssprop --async-color - Text/icon color used by default async state content
 * @cssprop --async-gap - Vertical spacing between icon, title, description, and actions
 * @cssprop --async-icon-size - Icon size for built-in loading/empty/error visuals
 * @example
 * ```html
 * <!-- Success state: default slot is shown -->
 * <sg-async status="success">
 *   <ul><li>Item one</li><li>Item two</li></ul>
 * </sg-async>
 *
 * <!-- Loading state: shows skeleton placeholders -->
 * <sg-async status="loading"></sg-async>
 *
 * <!-- Empty state with custom message -->
 * <sg-async status="empty" empty-label="No results" empty-description="Try adjusting your filters."></sg-async>
 *
 * <!-- Error state with retry button -->
 * <sg-async status="error" retryable error-label="Failed to load" error-description="Check your connection."></sg-async>
 * ```
 */
export const ASYNC_TAG = 'sg-async' as const;
define<SgAsyncProps, SgAsyncEvents>(ASYNC_TAG, {
  props: {
    'empty-description': prop.string(),
    'empty-label': prop.string('No content yet'),
    'error-description': prop.string(),
    'error-label': prop.string('Something went wrong'),
    retryable: prop.bool(false),
    status: prop.oneOf(['idle', 'loading', 'empty', 'error', 'success'] as const, 'success'),
  },
  setup(props, { bind, emit }) {
    const hasLoadingSlot = signal(false);
    const hasEmptySlot = signal(false);
    const hasErrorSlot = signal(false);

    // Reflect status onto the host so CSS can show/hide each region.
    // ARIA attributes are driven reactively by bind().
    bind({
      attr: {
        ariaBusy: () => (props.status!.value === 'loading' ? 'true' : 'false'),
        ariaLabel: () => (props.status!.value === 'loading' ? 'Loading…' : null),
        ariaLive: () => (props.status!.value === 'error' ? 'assertive' : 'polite'),
        status: props.status,
      },
    });

    const renderText =
      (className: 'title' | 'description', text: ReadonlySignal<string | undefined> | undefined) => () =>
        text?.value ? html`<p class="${className}">${text}</p>` : '';

    // All four regions are always in the shadow DOM — CSS on :host([status="…"])
    // toggles their visibility. This means:
    // - No DOM churn on status transitions (no teardown/rebuild of slot elements).
    // - Live regions are always present, so screen readers announce correctly.
    // - focus is never lost across status changes.
    return html`
      <div class="region region-idle" role="presentation"></div>

      <div class="region region-loading" role="status">
        <slot
          name="loading"
          @slotchange=${(e: Event) => {
            hasLoadingSlot.value = (e.target as HTMLSlotElement).assignedNodes().length > 0;
          }}></slot>
        ${when(
          hasLoadingSlot,
          () => html``,
          () => html`
            <div class="loading-default" aria-hidden="true">
              <sg-skeleton variant="text" lines="1" width="40%"></sg-skeleton>
              <sg-skeleton variant="text" lines="3" width="100%"></sg-skeleton>
              <sg-skeleton variant="text" lines="1" width="60%"></sg-skeleton>
            </div>
          `,
        )}
      </div>

      <div class="region region-empty">
        <slot
          name="empty"
          @slotchange=${(e: Event) => {
            hasEmptySlot.value = (e.target as HTMLSlotElement).assignedNodes().length > 0;
          }}></slot>
        ${when(
          hasEmptySlot,
          () => html``,
          () => html`
            <div class="empty-state" role="status">
              <div class="icon">
                <sg-icon name="inbox" size="100%" stroke-width="1.75" aria-hidden="true"></sg-icon>
              </div>
              ${renderText('title', props['empty-label'])} ${renderText('description', props['empty-description'])}
            </div>
          `,
        )}
      </div>

      <div class="region region-error">
        <slot
          name="error"
          @slotchange=${(e: Event) => {
            hasErrorSlot.value = (e.target as HTMLSlotElement).assignedNodes().length > 0;
          }}></slot>
        ${when(
          hasErrorSlot,
          () => html``,
          () => html`
            <div class="error-state" role="alert">
              <div class="icon">
                <sg-icon name="triangle-alert" size="100%" stroke-width="1.75" aria-hidden="true"></sg-icon>
              </div>
              ${renderText('title', props['error-label'])} ${renderText('description', props['error-description'])}
              ${when(
                () => Boolean(props.retryable!.value),
                () => html`
                  <button class="retry-btn" type="button" @click=${() => emit('retry')}>
                    <sg-icon name="refresh-cw" size="1em" stroke-width="2" aria-hidden="true"></sg-icon>
                    Try again
                  </button>
                `,
              )}
            </div>
          `,
        )}
      </div>

      <div class="region region-success" role="presentation">
        <slot></slot>
      </div>
    `;
  },
  styles: [reducedMotionMixin, componentStyles],
});

Basic Usage

html
<sg-async status="loading"></sg-async>

Switch status from your data layer:

js
const el = document.querySelector('sg-async');

async function loadData() {
  el.status = 'loading';
  try {
    const data = await fetch('/api/items').then((r) => r.json());
    el.status = data.length ? 'success' : 'empty';
  } catch {
    el.status = 'error';
  }
}

Status: Loading

The default loading view renders a skeleton stack automatically. No slot required.

PreviewCode
RTL

Custom Loading Slot

PreviewCode
RTL

Status: Empty

PreviewCode
RTL

Custom Empty Slot

PreviewCode
RTL

Status: Error

PreviewCode
RTL

Custom Error Slot

PreviewCode
RTL

Status: Success

PreviewCode
RTL

Retry

Add retryable to show a built-in retry button in the error state. Listen for the retry event to re-trigger your fetch.

PreviewCode
RTL

Composing with sg-card

PreviewCode
RTL

Composing with sg-table

Use the loading slot to render a table-shaped skeleton that matches the real table layout. When data arrives, switch status to success and the real table appears.

PreviewCode
RTL

Status values

statusWhat rendersaria-busyaria-live
idlenothing (empty region)falsepolite
loadingloading slot or skeleton stacktruepolite
emptyempty slot or built-in empty statefalsepolite
errorerror slot or built-in error statefalseassertive
successdefault slot (your content)falsepolite

Props

AttributeTypeDefaultDescription
statusAsyncStatus'success'Current data-fetch status
empty-labelstring'No content yet'Heading for the built-in empty state
empty-descriptionstringDescription below the empty-state heading
error-labelstring'Something went wrong'Heading for the built-in error state
error-descriptionstringDescription below the error-state heading
retryablebooleanfalseShow retry button in the built-in error state

Events

EventDetailDescription
retryFired when the built-in retry button is clicked

Slots

SlotDescription
(default)Rendered when status="success"
loadingReplaces the built-in skeleton stack during loading
emptyReplaces the built-in empty state illustration
errorReplaces the built-in error view

CSS Custom Properties

PropertyDefaultDescription
--async-color--color-contrast-500Icon/text color for built-in states
--async-icon-sizevar(--size-12)Icon size in built-in empty/error views
--async-gapvar(--size-3)Gap between elements in built-in views

Accessibility

sg-async manages ARIA on the host element automatically:

  • aria-busy="true" while status is loading — screen readers announce the busy region.
  • aria-live="assertive" in the error state — error messages interrupt immediately.
  • aria-live="polite" in all other states — updates are announced after the current action.
  • The built-in error region uses role="alert" for immediate screen reader pickup.
  • The built-in loading and empty regions use role="status" for polite announcements.
  • The retry button is typed type="button" to prevent accidental form submission.