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 ARIA —
aria-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.
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 bit-card
Composing with bit-table
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
bit-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.