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, onMount, signal } from '@vielzeug/craftit';
import { choose, when } from '@vielzeug/craftit/directives';

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

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

export type BitAsyncEvents = {
  retry: void;
};

export type BitAsyncProps = {
  /** Description shown below the empty label in the default empty state */
  'empty-description'?: string;
  /** Descriptive label for the empty state, shown when no custom `empty` slot is provided */
  'empty-label'?: string;
  /** Detailed error text shown below the error label in the default error state */
  'error-description'?: string;
  /** Descriptive label for the error state, shown when no custom `error` slot is provided */
  'error-label'?: string;
  /** Whether to show the retry button in the default error state */
  retryable?: boolean;
  /**
   * Current data-fetch status.
   * - `idle`    — not yet started; renders nothing
   * - `loading` — shows the `loading` slot (or a default skeleton stack)
   * - `empty`   — shows the `empty` slot (or a built-in empty-state illustration)
   * - `error`   — shows the `error` slot (or a built-in error state with optional retry)
   * - `success` — shows the default slot (the actual content)
   */
  status: AsyncStatus;
};

/**
 * A composable wrapper that renders the correct UI for each async data-fetch status.
 * Drives `aria-live` and `aria-busy` automatically so screen readers stay informed.
 *
 * @element bit-async
 *
 * @attr {string} status - Data status: 'idle' | 'loading' | 'empty' | 'error' | 'success'
 * @attr {string} empty-label - Heading for the default empty state
 * @attr {string} empty-description - Description for the default empty state
 * @attr {string} error-label - Heading for the default error state
 * @attr {string} error-description - Description for the default error state
 * @attr {boolean} retryable - Show a retry button in the default error state
 *
 * @fires retry - Emitted when the retry button is clicked
 *
 * @slot - Shown when `status="success"` (default)
 * @slot loading - Shown when `status="loading"` (defaults to skeleton stack)
 * @slot empty - Shown when `status="empty"` (defaults to built-in illustration)
 * @slot error - Shown when `status="error"` (defaults to built-in error view)
 *
 * @cssprop --async-color - Icon/text color for default empty/error states
 * @cssprop --async-icon-size - Icon size in default states (default: var(--size-12))
 * @cssprop --async-gap - Gap between elements in default states (default: var(--size-3))
 *
 * @example
 * ```html
 * <!-- Simple usage — let buildit handle empty and error UI -->
 * <bit-async status="loading" empty-label="No results" error-label="Failed to load" retryable>
 *   <my-data-table></my-data-table>
 * </bit-async>
 *
 * <!-- Custom empty slot -->
 * <bit-async status="empty">
 *   <div slot="empty">
 *     <img src="/no-results.svg" alt="" />
 *     <p>Try adjusting your filters.</p>
 *   </div>
 * </bit-async>
 * ```
 */
export const ASYNC_TAG = define<BitAsyncProps, BitAsyncEvents>('bit-async', {
  props: {
    'empty-description': undefined,
    'empty-label': 'No content yet',
    'error-description': undefined,
    'error-label': 'Something went wrong',
    retryable: false,
    // Default to success so slotted content is visible without extra wiring.
    status: 'success',
  },
  setup({ emit, host, props }) {
    const hasLoadingSlot = signal(false);
    const hasEmptySlot = signal(false);
    const hasErrorSlot = signal(false);

    const updateNamedSlotPresence = () => {
      const children = Array.from(host.el.children);

      hasLoadingSlot.value = children.some((child) => child.getAttribute('slot') === 'loading');
      hasEmptySlot.value = children.some((child) => child.getAttribute('slot') === 'empty');
      hasErrorSlot.value = children.some((child) => child.getAttribute('slot') === 'error');
    };

    updateNamedSlotPresence();

    onMount(() => {
      updateNamedSlotPresence();

      const observer = new MutationObserver(() => updateNamedSlotPresence());

      observer.observe(host.el, { attributeFilter: ['slot'], attributes: true, childList: true, subtree: true });

      return () => observer.disconnect();
    });

    // Keep host accessibility state in sync with async status.
    host.bind('attr', {
      ariaBusy: () => (props.status.value === 'loading' ? 'true' : 'false'),
      ariaLabel: () => (props.status.value === 'loading' ? 'Loading…' : null),
      ariaLive: () => (props.status.value === 'error' ? 'assertive' : 'polite'),
    });

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

    const renderDefaultState = ({
      action,
      description,
      icon,
      label,
      role,
      stateClass,
    }: {
      action?: () => unknown;
      description: { value: string | undefined };
      icon: string;
      label: { value: string | undefined };
      role: 'alert' | 'status';
      stateClass: 'empty-state' | 'error-state';
    }) => html`
      <div class="${stateClass}" role="${role}">
        <div class="icon">
          <bit-icon name="${icon}" size="100%" stroke-width="1.75" aria-hidden="true"></bit-icon>
        </div>
        ${renderText('title', label)} ${renderText('description', description)} ${action?.()}
      </div>
    `;

    const renderLoadingFallback = () => html`
      <div class="loading-default" aria-hidden="true">
        <bit-skeleton variant="text" lines="1" width="40%"></bit-skeleton>
        <bit-skeleton variant="text" lines="3" width="100%"></bit-skeleton>
        <bit-skeleton variant="text" lines="1" width="60%"></bit-skeleton>
      </div>
    `;

    const renderSuccess = () => html`
      <div class="region" role="presentation">
        <slot></slot>
      </div>
    `;

    return html`${choose({
      cases: [
        ['idle', () => html`<div class="region" role="presentation"></div>`],

        [
          'loading',
          () => html`
            <div class="region" role="status">
              ${when({
                condition: () => hasLoadingSlot.value,
                else: renderLoadingFallback,
                then: () => html`<slot name="loading"></slot>`,
              })}
            </div>
          `,
        ],

        [
          'empty',
          () => html`
            <div class="region">
              ${when({
                condition: () => hasEmptySlot.value,
                else: () =>
                  renderDefaultState({
                    description: props['empty-description'],
                    icon: 'inbox',
                    label: props['empty-label'],
                    role: 'status',
                    stateClass: 'empty-state',
                  }),
                then: () => html`<slot name="empty"></slot>`,
              })}
            </div>
          `,
        ],

        [
          'error',
          () => html`
            <div class="region">
              ${when({
                condition: () => hasErrorSlot.value,
                else: () =>
                  renderDefaultState({
                    action: () =>
                      when({
                        condition: () => props.retryable.value,
                        then: () => html`
                          <button class="retry-btn" type="button" @click=${() => emit('retry')}>
                            <bit-icon name="refresh-cw" size="1em" stroke-width="2" aria-hidden="true"></bit-icon>
                            Try again
                          </button>
                        `,
                      }),
                    description: props['error-description'],
                    icon: 'triangle-alert',
                    label: props['error-label'],
                    role: 'alert',
                    stateClass: 'error-state',
                  }),
                then: () => html`<slot name="error"></slot>`,
              })}
            </div>
          `,
        ],

        ['success', renderSuccess],
      ],
      fallback: renderSuccess,
      value: props.status,
    })}`;
  },
  styles: [reducedMotionMixin, componentStyles],
});

Basic Usage

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

<script type="module">
  import '@vielzeug/buildit/async';
  import '@vielzeug/buildit/skeleton';
</script>

Switch status from your data layer:

js
const el = document.querySelector('bit-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 bit-card

PreviewCode
RTL

Composing with bit-table

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

bit-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.