Loading State
Loading State gives pending content one calm, useful status surface. The application marks the region that is busy, names the work in plain language, and decides whether to show a product mark, caller-written skeletons, or content that is already useful.
It is one component. There is no request prop, timer, data-fetching hook, skeleton component, visual variant, full-page mode, or product copy hidden behind its API.
Installation
The command detects Vue, React, or Svelte and copies the matching one-file source into the conventional UI directory.
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add loading-state- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
The examples also use Spinner. Add it separately with npx klean-ui add spinner, or place an application-owned mark or skeleton inside Loading State.
Usage
Put aria-busy on the region whose content is changing. Loading State provides the persistent polite status; the caller supplies useful words and optional visuals.
Vue
<script setup>
import LoadingState from '@/components/ui/loading-state/LoadingState.vue'
import Spinner from '@/components/ui/spinner/Spinner.vue'
</script>
<template>
<section aria-busy="true" aria-labelledby="services-title">
<h2 id="services-title">Services</h2>
<LoadingState>
<Spinner class="size-6" />
<span>Loading services…</span>
</LoadingState>
</section>
</template>
React
import LoadingState from '@/components/ui/loading-state/LoadingState.jsx'
import Spinner from '@/components/ui/spinner/Spinner.jsx'
export default function ServicesLoading() {
return (
<section aria-busy="true" aria-labelledby="services-title">
<h2 id="services-title">Services</h2>
<LoadingState>
<Spinner className="size-6" />
<span>Loading services…</span>
</LoadingState>
</section>
)
}
Svelte
<script>
import LoadingState from '@/components/ui/loading-state/LoadingState.svelte'
import Spinner from '@/components/ui/spinner/Spinner.svelte'
</script>
<section aria-busy="true" aria-labelledby="services-title">
<h2 id="services-title">Services</h2>
<LoadingState>
<Spinner class="size-6" />
<span>Loading services…</span>
</LoadingState>
</section>
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Content | default slot | children | children snippet |
| Root styling | class | className | class |
| Element access | template ref | forwarded ref | component binding |
Native div attributes and events pass through. The root remains a polite, atomic status so the same content is not accidentally made assertive by styling code.
There are no loading, delay, promise, skeleton, title, description, variant, tone, compact, fullPage, retry, or cancel props. Request state, timing, content, and recovery stay visible in application code.
Loading State, Spinner, and skeletons
- Loading State is the semantic and layout boundary for pending content.
- Spinner is an optional decorative mark inside a button or Loading State.
- Skeletons describe the shape of one application's content, so write them as ordinary caller markup inside Loading State.
Spinner alone does not name the work. A skeleton alone should be hidden from assistive technology and paired with useful status text. Loading State can contain either, both, or neither.
Initial loads and refreshes
For the first load, it is reasonable for Loading State to occupy the content region. Keep the region's heading available and mark that region busy.
For a refresh, preserve content that is still useful. Place a compact Loading State beside the region heading and keep the existing rows, chart, or summary readable until the new response arrives. This avoids destructive flicker and retains context during Inertia partial reloads.
The application decides when pending work starts and ends. Loading State does not delay its appearance or invent a minimum duration; those decisions depend on the real request and whether stale content is safe.
Accessibility
- The root is a persistent
role="status"with polite, atomic announcements. - Put
aria-busy="true"on the affected button, form, table region, or section—not on an unrelated page wrapper. - Write specific text such as “Loading invoices…” or “Refreshing deployments…”.
- Hide decorative spinners and skeleton shapes from assistive technology.
- Respect reduced motion on caller-authored skeleton animation with
motion-reduce:animate-none. - Do not move focus when loading begins or ends. Preserve the control or content context the user already has.
- When a submission failure needs immediate attention, use a contextual error surface rather than changing Loading State into an alert.
Styling with Tailwind
The baseline is a centered, wrapping column with a useful minimum height. class or className merges onto the root, so applications can create compact refresh text, a full content-region loader, or a left-aligned skeleton with ordinary Tailwind.
The visual content remains caller markup. Slipway can keep its animated Slippy mark, Hagfish can keep its sharp invoice skeletons, and neither product treatment becomes a Klean prop.
When to use
Use Loading State when a named region is waiting for content or refreshing existing content and a visible, accessible status will help the user understand what is happening.
When not to use
- Use Spinner directly inside a pending button when the button text already names the work.
- Use Empty State only after a request succeeds and there is genuinely nothing to show.
- Use Alert for contextual warnings or recoverable failures.
- Use Toast for transient feedback after an action completes.
- Use native
<progress>when meaningful progress can be measured.
Complete framework source
Vue
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
const element = ref()
const rootAttrs = computed(() => {
const {
class: _class,
role: _role,
'aria-live': _ariaLive,
'aria-atomic': _ariaAtomic,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
defineExpose({ element })
</script>
<template>
<div
ref="element"
v-bind="rootAttrs"
role="status"
aria-live="polite"
aria-atomic="true"
data-slot="loading-state"
:class="
twMerge(
'flex min-h-32 w-full flex-col items-center justify-center gap-3 p-6 text-center text-gray-600 dark:text-gray-300',
attrs.class
)
"
>
<slot />
</div>
</template>
React
import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'
const BASE_CLASSES =
'flex min-h-32 w-full flex-col items-center justify-center gap-3 p-6 text-center text-gray-600 dark:text-gray-300'
const LoadingState = forwardRef(function LoadingState(
{
className,
role: _role,
'aria-live': _ariaLive,
'aria-atomic': _ariaAtomic,
'data-slot': _dataSlot,
...props
},
ref
) {
return (
<div
{...props}
ref={ref}
role="status"
aria-live="polite"
aria-atomic="true"
data-slot="loading-state"
className={twMerge(BASE_CLASSES, className)}
/>
)
})
export default LoadingState
Svelte
<script>
import { twMerge } from "tailwind-merge";
const BASE_CLASSES =
"flex min-h-32 w-full flex-col items-center justify-center gap-3 p-6 text-center text-gray-600 dark:text-gray-300";
let {
children,
class: className,
role: _role,
"aria-live": _ariaLive,
"aria-atomic": _ariaAtomic,
"data-slot": _dataSlot,
...rootProps
} = $props();
let element = $state();
export function getElement() {
return element;
}
</script>
<div
{...rootProps}
bind:this={element}
role="status"
aria-live="polite"
aria-atomic="true"
data-slot="loading-state"
class={twMerge(BASE_CLASSES, className)}
>
{@render children?.()}
</div>
Related components
- Spinner — supplies an optional decorative loading mark.
- Empty State — represents a successfully loaded surface with no content.
- DataTable — can preserve rows and expose a compact refresh status.
- Button — owns truthful pending and disabled state for commands.
- Alert — communicates a warning or recoverable failure instead of pending work.
- Toast — announces transient completion feedback.