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,successDefault 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 ARIA — aria-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.
Custom Loading Slot
Status: Empty
Custom Empty Slot
Status: Error
Custom Error Slot
Status: Success
Retry
Add retryable to show a built-in retry button in the error state. Listen for the retry event to re-trigger your fetch.
Composing with sg-card
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.
Status values
status | What renders | aria-busy | aria-live |
|---|---|---|---|
idle | nothing (empty region) | false | polite |
loading | loading slot or skeleton stack | true | polite |
empty | empty slot or built-in empty state | false | polite |
error | error slot or built-in error state | false | assertive |
success | default slot (your content) | false | polite |
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
status | AsyncStatus | 'success' | Current data-fetch status |
empty-label | string | 'No content yet' | Heading for the built-in empty state |
empty-description | string | — | Description below the empty-state heading |
error-label | string | 'Something went wrong' | Heading for the built-in error state |
error-description | string | — | Description below the error-state heading |
retryable | boolean | false | Show retry button in the built-in error state |
Events
| Event | Detail | Description |
|---|---|---|
retry | — | Fired when the built-in retry button is clicked |
Slots
| Slot | Description |
|---|---|
| (default) | Rendered when status="success" |
loading | Replaces the built-in skeleton stack during loading |
empty | Replaces the built-in empty state illustration |
error | Replaces the built-in error view |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--async-color | --color-contrast-500 | Icon/text color for built-in states |
--async-icon-size | var(--size-12) | Icon size in built-in empty/error views |
--async-gap | var(--size-3) | Gap between elements in built-in views |
Accessibility
sg-async manages ARIA on the host element automatically:
aria-busy="true"while status isloading— screen readers announce the busy region.aria-live="assertive"in theerrorstate — 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.