Loading
Spinners, loading messages, logo loaders, overlays for refetching data and a top-of-page loading bar. The message and spinner are Server Components and fine in loading.tsx.
View as Markdown for coding assistants, or see llms.txt.
- Stable
- Group: Feedback
- Added in @azmr/ui 0.12.0
Usage
import { Loading, Spinner } from "@azmr/ui/loading"; // Server Components
import { LoadingBar, LoadingOverlay, useLoadingState } from "@azmr/ui/loading/client";<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-6">
<Spinner size="xs" />
<Spinner size="sm" />
<Spinner size="md" tone="default" />
<Spinner size="lg" tone="success" />
<Spinner size="xl" tone="info" />
</div>
<div className="flex flex-wrap items-center gap-8">
<Loading />
<Loading label="Loading data" tone="default" />
<Loading label="Loading invoices…" labelPlacement="bottom" size="md" tone="default" />
</div>
</div>Spinner is the bare ring: size from xs to xl (or inherit, 1em, for inside text and buttons) and tone for its colour. Loading adds a message beside or below it (labelPlacement) in a role="status" region, so it is shown and announced. Every part takes className and style, merged last, so any default can be overridden.
With your logo
<div className="flex flex-wrap items-end gap-10">
{/* The ring spins; the logo stays upright and readable. */}
<Loading logo={<Logo />} label="Loading" labelPlacement="bottom" tone="default" />
<Loading logo={<Logo />} size="xl" label="Loading data" labelPlacement="bottom" />
{/* No ring: the logo pulses. */}
<Loading
logo={<Logo />}
animation="pulse"
size="xl"
label="Starting up"
labelPlacement="bottom"
/>
</div>Pass logo (an <img>, an <svg> or an Avatar) and a ring spins around it while the logo itself stays upright, so it is always readable. animation="pulse" drops the ring and fades the logo in and out instead. The logo is decorative; the message is what gets announced, so give images alt="".
Pages and sections
variant="page" fills the content area and variant="section" fills its container. Both put the message under the spinner and wait 200 ms before fading in, so a fast load shows nothing at all. The delay is CSS (animation-delay on opacity), so it works with no client JavaScript.
import { Loading } from "@azmr/ui/loading";
export default function InvoicesLoading() {
return <Loading variant="page" label="Loading invoices…" />;
}import { Suspense } from "react";
import { Loading } from "@azmr/ui/loading";
<Suspense fallback={<Loading variant="section" label="Loading recent payments…" />}>
<RecentPayments />
</Suspense>delay={0} shows it at once; any other number of milliseconds sets the wait.
Refetching data that is already on screen
- INV-2026-001$1,150.00
- INV-2026-002$460.00
- INV-2026-003$89.70
<div className="flex max-w-md flex-col gap-4">
<Button onClick={refresh} disabled={refreshing} className="self-start">
Refresh invoices
</Button>
<LoadingOverlay active={refreshing} label="Refreshing invoices" className="rounded-md border">
<ul className="divide-y text-sm">
<li className="flex justify-between p-3">
<span>INV-2026-001</span>
<span>$1,150.00</span>
</li>
<li className="flex justify-between p-3">
<span>INV-2026-002</span>
<span>$460.00</span>
</li>
<li className="flex justify-between p-3">
<span>INV-2026-003</span>
<span>$89.70</span>
</li>
</ul>
</LoadingOverlay>
</div>When data is already showing and is being refreshed, replacing it with a spinner throws away what the user was reading. LoadingOverlay keeps it: the content dims, the region gets aria-busy, and a spinner with the message sits on top.
It shows only once active has been true for delay (default 200 ms), and then stays for at least minDuration (default 400 ms), so a quick refetch never flickers. The same timing is available on its own as useLoadingState(loading, { delay, minDuration }), which returns whether to show your own indicator.
Top-of-page loading bar
// `absolute` keeps the demo's bar inside this frame; in an app it is fixed to the top of the page.
<div className="relative flex gap-3 overflow-hidden rounded-md border p-6 pt-8">
<LoadingBar loading={loading} value={value} className="absolute" />
<Button onClick={navigate} disabled={loading}>
Go to invoices
</Button>
<Button variant="outline" onClick={upload} disabled={loading}>
Upload (known progress)
</Button>
</div>LoadingBar is a thin bar fixed to the top of the page, like GitHub's. While loading is true it creeps towards 90% and slows as it gets there; when loading turns false it fills to 100% and fades away. A load faster than delay (default 150 ms) shows nothing. Pass value (0 to 100) when the real progress is known, and it shows that instead.
It works with any router: drive loading from your own pending state. In a Next.js App Router app, wrap navigations or Server Actions in a transition and pass its pending flag:
"use client";
import { LoadingBar } from "@azmr/ui/loading/client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
export function InvoiceLink({ id }: { id: string }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<>
<LoadingBar loading={pending} />
<button type="button" onClick={() => startTransition(() => router.push(`/invoices/${id}`))}>
Open
</button>
</>
);
}The fill moves with transform: scaleX, never width.
Reduced motion
A spinner, a ring around a logo and a pulsing logo keep moving under prefers-reduced-motion (data-motion="essential"): a frozen loader reads as a hung page. Fades and the bar's movement follow the user's setting and become instant; the delay still applies.
Props
Loading
Prop
Type
Spinner
Prop
Type
LoadingOverlay
Prop
Type
LoadingBar
Prop
Type
Accessibility
Loadingis onerole="status"region: its message is announced once, politely. WithlabelPlacement="hidden"the message is kept for screen readers only.- The spinner, the ring and the logo are
aria-hidden; only the message is read. LoadingOverlaykeeps an empty status region in the page and fills it when the overlay appears, so the message is announced when it shows, and setsaria-busyon the content while it is stale.LoadingBaris aprogressbarnamed "Loading" by default (aria-label). It has noaria-valuenowwhile creeping, because that number is not real; with a knownvalueit reports it.- Nothing here is focusable or takes keyboard input.