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

Source: https://platform.azmara.io/docs/components/loading

Group: Feedback · Status: stable · Added in @azmr/ui 0.12.0

## Usage

```tsx
import { Loading, Spinner } from "@azmr/ui/loading"; // Server Components
import { LoadingBar, LoadingOverlay, useLoadingState } from "@azmr/ui/loading/client";
```

```tsx
import { Loading, Spinner } from "@azmr/ui/loading";

export default function LoadingBasic() {
  return (
    <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

```tsx
import { Loading } from "@azmr/ui/loading";

/** Stands in for your logo: any <img>, <svg> or Avatar works. */
function Logo() {
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true">
      <circle cx="16" cy="16" r="16" className="fill-primary" />
      <path
        d="M9.5 23 16 8.5 22.5 23M12 17.5h8"
        fill="none"
        strokeWidth="2.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        className="stroke-primary-foreground"
      />
    </svg>
  );
}

export default function LoadingLogo() {
  return (
    <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.

```tsx
import { Loading } from "@azmr/ui/loading";

export default function InvoicesLoading() {
  return <Loading variant="page" label="Loading invoices…" />;
}
```

```tsx
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

```tsx
"use client";

import { Button } from "@azmr/ui/button";
import { LoadingOverlay } from "@azmr/ui/loading/client";
import { useState } from "react";

export default function LoadingOverlayDemo() {
  const [refreshing, setRefreshing] = useState(false);

  function refresh() {
    setRefreshing(true);
    setTimeout(() => setRefreshing(false), 1500);
  }

  return (
    <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

```tsx
"use client";

import { Button } from "@azmr/ui/button";
import { LoadingBar } from "@azmr/ui/loading/client";
import { useState } from "react";

export default function LoadingBarDemo() {
  const [loading, setLoading] = useState(false);
  const [value, setValue] = useState<number | null>(null);

  function navigate() {
    setValue(null);
    setLoading(true);
    setTimeout(() => setLoading(false), 2500);
  }

  function upload() {
    setLoading(true);
    let done = 0;
    setValue(done);
    const timer = setInterval(() => {
      done += 20;
      setValue(done);
      if (done >= 100) {
        clearInterval(timer);
        setLoading(false);
      }
    }, 400);
  }

  return (
    // `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:

```tsx
"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                                                                                       | Default | Required | Description                                                                                                                                                                      |
| ---------------- | ------------------------------------------------------------------------------------------ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `className`      | `string`                                                                                   |         | No       |                                                                                                                                                                                  |
| `size`           | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| "inherit" \| undefined`                           |         | No       | Default `sm` inline, `lg` in a section or page or around a `logo`.                                                                                                               |
| `tone`           | `"current" \| "default" \| "success" \| "warning" \| "destructive" \| "info" \| undefined` |         | No       | Default `current` inline (the text colour), `default` (primary) in a section or page.                                                                                            |
| `logo`           | `ReactNode`                                                                                |         | No       | A logo or image shown in the middle of the spinner (`<img>`, `<svg>`, an `Avatar`). It fills a circle inside the ring. The logo stays upright and readable; only the ring moves. |
| `animation`      | `"ring" \| "pulse" \| undefined`                                                           |         | No       | `ring` (default): a ring spins around the logo. `pulse`: no ring; the logo fades in and out.                                                                                     |
| `label`          | `string`                                                                                   |         | No       | The message, shown and announced. Default `"Loading…"`.                                                                                                                          |
| `labelPlacement` | `"hidden" \| "end" \| "bottom" \| undefined`                                               |         | No       | Default `end` (beside the spinner) inline, `bottom` in a section or page. `hidden` keeps it for screen readers only.                                                             |
| `variant`        | `"inline" \| "section" \| "page" \| undefined`                                             |         | No       | `inline` (default) sits in a line of content. `section` fills its container, for a `<Suspense fallback>`. `page` fills the content area, for a Next.js `loading.tsx`.            |
| `delay`          | `number`                                                                                   |         | No       | Milliseconds to stay invisible before fading in, so a fast load never flashes it. Default 0 inline, 200 in a section or page. Pure CSS, so it works in a Server Component.       |

### Spinner

| Prop        | Type                                                                                       | Default | Required | Description                                                                                                                                                                                                                                                                                                                                                  |
| ----------- | ------------------------------------------------------------------------------------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `className` | `string`                                                                                   |         | No       |                                                                                                                                                                                                                                                                                                                                                              |
| `label`     | `string`                                                                                   |         | No       | Accessible name. When set the spinner is announced as a status ("Loading customers"); when omitted it is decorative and hidden from assistive technology (use that when the surrounding control already says it is busy, as `Button loading` does). For visible text, a logo in the middle or a whole loading region, use `Loading` from `@azmr/ui/loading`. |
| `size`      | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| "inherit" \| undefined`                           |         | No       | Default `sm` (1rem). `inherit` is 1em. A `size-*` class still overrides it.                                                                                                                                                                                                                                                                                  |
| `tone`      | `"current" \| "default" \| "success" \| "warning" \| "destructive" \| "info" \| undefined` |         | No       | Default `current`, the surrounding text colour.                                                                                                                                                                                                                                                                                                              |
| `delay`     | `number`                                                                                   |         | No       | Milliseconds to stay invisible before fading in, so a fast load never flashes a spinner. Pure CSS, so it works in Server Components and `loading.tsx`.                                                                                                                                                                                                       |

### LoadingOverlay

| Prop             | Type                                                                                       | Default | Required | Description                                                                                                                                                                      |
| ---------------- | ------------------------------------------------------------------------------------------ | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `className`      | `string`                                                                                   |         | No       |                                                                                                                                                                                  |
| `delay`          | `number`                                                                                   |         | No       | Milliseconds `loading` must stay true before the indicator shows. Default 200.                                                                                                   |
| `minDuration`    | `number`                                                                                   |         | No       | Once shown, the indicator stays at least this many milliseconds. Default 400.                                                                                                    |
| `size`           | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| "inherit" \| undefined`                           |         | No       | Default `sm` inline, `lg` in a section or page or around a `logo`.                                                                                                               |
| `tone`           | `"current" \| "default" \| "success" \| "warning" \| "destructive" \| "info" \| undefined` |         | No       | Default `current` inline (the text colour), `default` (primary) in a section or page.                                                                                            |
| `logo`           | `ReactNode`                                                                                |         | No       | A logo or image shown in the middle of the spinner (`<img>`, `<svg>`, an `Avatar`). It fills a circle inside the ring. The logo stays upright and readable; only the ring moves. |
| `animation`      | `"ring" \| "pulse" \| undefined`                                                           |         | No       | `ring` (default): a ring spins around the logo. `pulse`: no ring; the logo fades in and out.                                                                                     |
| `active`         | `boolean`                                                                                  |         | Yes      | True while the content is being refetched. Shows after `delay`, for at least `minDuration`.                                                                                      |
| `children`       | `ReactNode`                                                                                |         | Yes      | The content that stays on screen, dimmed, while `active`.                                                                                                                        |
| `label`          | `string`                                                                                   |         | No       | The message, shown and announced when the overlay appears. Default `"Loading…"`.                                                                                                 |
| `labelPlacement` | `"hidden" \| "end" \| "bottom" \| undefined`                                               |         | No       | `bottom` (default) under the spinner, `end` beside it, `hidden` for screen readers only.                                                                                         |

### LoadingBar

| Prop         | Type                                                                          | Default | Required | Description                                                                                            |
| ------------ | ----------------------------------------------------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------ |
| `className`  | `string`                                                                      |         | No       |                                                                                                        |
| `loading`    | `boolean`                                                                     |         | Yes      | True while loading. The bar creeps towards 90%, then fills and fades out when it turns false.          |
| `value`      | `number \| null \| undefined`                                                 |         | No       | The real progress (0 to 100), when it is known. Replaces the creep; omit or `null` when it is not.     |
| `delay`      | `number`                                                                      |         | No       | Milliseconds `loading` must stay true before the bar shows, so a fast load shows nothing. Default 150. |
| `tone`       | `"success" \| "destructive" \| "info" \| "default" \| "warning" \| undefined` |         | No       | Default `default` (primary).                                                                           |
| `aria-label` | `string`                                                                      |         | No       | Accessible name. Default `"Loading"`.                                                                  |

## Accessibility

- `Loading` is one `role="status"` region: its message is announced once, politely. With `labelPlacement="hidden"` the message is kept for screen readers only.
- The spinner, the ring and the logo are `aria-hidden`; only the message is read.
- `LoadingOverlay` keeps an empty status region in the page and fills it when the overlay appears, so the message is announced when it shows, and sets `aria-busy` on the content while it is stale.
- `LoadingBar` is a `progressbar` named "Loading" by default (`aria-label`). It has no `aria-valuenow` while creeping, because that number is not real; with a known `value` it reports it.
- Nothing here is focusable or takes keyboard input.
