# Loading overlay

Covers a region, or the whole window, with a mask and a spinner while it is busy, and blocks it until it is done.

- **Import:** `import { LoadingOverlay } from "@booleanpress/ui/loading-overlay"`
- **Page:** <https://ui.booleanpress.com/components/loading-overlay> · @booleanpress/ui 0.2.0

## Usage

Wrap the region that is busy and set `loading`. While it is, the mask covers it, nothing inside can be clicked, typed in or focused, and the label is announced.

```tsx
import { Card } from "@booleanpress/ui/card"
import { LoadingOverlay } from "@booleanpress/ui/loading-overlay"

export function MailerCard({ saving }: { saving: boolean }) {
  return (
    <LoadingOverlay loading={saving} label="Saving the mailer…" className="rounded-xl">
      <Card>…</Card>
    </LoadingOverlay>
  )
}
```

`label` is the text beside the spinner and what is announced; without it the provider's `loading` string is announced and only the spinner shows. `indicator` replaces the spinner, for example with a [progress circle](/components/progress-circle). The mask follows the wrapper's corners: give it your region's radius (`className="rounded-xl"` for a card).

`fullScreen` covers the whole window, makes the rest of the page inert and stops it scrolling; it needs no children. Several full-screen overlays can block at once: the page is usable again when the last one ends. Use it for work that changes the whole page, such as an import, and end it from code when the work is done.

For content that is loading for the first time, show a [skeleton](/components/skeleton) instead; the overlay is for content that is already there and is being saved or refreshed.

## Examples

### Region

Save blocks the card for two seconds; focus returns to Save when it ends.

```tsx
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@booleanpress/ui/card"
import { Input } from "@booleanpress/ui/input"
import { Label } from "@booleanpress/ui/label"
import { LoadingOverlay } from "@booleanpress/ui/loading-overlay"

export default function LoadingOverlayRegion() {
  const [saving, setSaving] = useState(false)

  // Stands in for the request that saves the mailer.
  const save = () => {
    setSaving(true)
    setTimeout(() => setSaving(false), 2000)
  }

  return (
    <LoadingOverlay loading={saving} className="w-full max-w-sm rounded-xl">
      <Card>
        <CardHeader>
          <CardTitle>Primary SMTP</CardTitle>
          <CardDescription>The sender address of every email.</CardDescription>
        </CardHeader>
        <CardContent className="grid gap-2">
          <Label htmlFor="overlay-from">From address</Label>
          <Input id="overlay-from" defaultValue="hello@example.com" />
        </CardContent>
        <CardFooter className="justify-end">
          <Button onClick={save}>Save</Button>
        </CardFooter>
      </Card>
    </LoadingOverlay>
  )
}
```

### Full screen

`fullScreen` covers the window while the import runs, and ends on its own after two seconds.

```tsx
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { LoadingOverlay } from "@booleanpress/ui/loading-overlay"

export default function LoadingOverlayFullScreen() {
  const [importing, setImporting] = useState(false)

  // Stands in for the import; the overlay ends on its own after 2 seconds.
  const start = () => {
    setImporting(true)
    setTimeout(() => setImporting(false), 2000)
  }

  return (
    <>
      <Button variant="outline" onClick={start}>
        Import settings
      </Button>
      <LoadingOverlay fullScreen loading={importing} label="Importing settings…" />
    </>
  )
}
```

### With text

A `label` beside the spinner, announced when loading starts.

```tsx
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { LoadingOverlay } from "@booleanpress/ui/loading-overlay"

const STATS = [
  { label: "Sent", value: "12,480" },
  { label: "Delivered", value: "12,301" },
  { label: "Bounced", value: "179" },
]

export default function LoadingOverlayWithText() {
  const [refreshing, setRefreshing] = useState(true)

  return (
    <div className="flex w-full max-w-md flex-col items-end gap-3">
      <LoadingOverlay loading={refreshing} label="Refreshing statistics…" className="w-full">
        <dl className="grid grid-cols-3 gap-3 rounded-md border p-4">
          {STATS.map((stat) => (
            <div key={stat.label}>
              <dt className="text-xs text-muted-foreground">{stat.label}</dt>
              <dd className="text-lg font-semibold tabular-nums">{stat.value}</dd>
            </div>
          ))}
        </dl>
      </LoadingOverlay>
      <Button variant="outline" size="sm" onClick={() => setRefreshing((value) => !value)}>
        {refreshing ? "Stop" : "Refresh"}
      </Button>
    </div>
  )
}
```

### Custom indicator

A ProgressCircle in place of the spinner, filling as the upload goes.

```tsx
import { useEffect, useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { LoadingOverlay } from "@booleanpress/ui/loading-overlay"
import { ProgressCircle } from "@booleanpress/ui/progress-circle"

export default function LoadingOverlayCustomIndicator() {
  const [progress, setProgress] = useState<number | null>(null)

  // Stands in for an upload that reports its progress: 10 steps of 300 ms.
  useEffect(() => {
    if (progress === null) return
    const timer = setTimeout(() => setProgress(progress >= 100 ? null : progress + 10), 300)
    return () => clearTimeout(timer)
  }, [progress])

  return (
    <div className="flex w-full max-w-sm flex-col items-end gap-3">
      <LoadingOverlay
        loading={progress !== null}
        label="Uploading the template…"
        indicator={<ProgressCircle value={progress ?? 0} size="sm" aria-label="Upload progress" />}
        className="w-full"
      >
        <div className="rounded-md border p-4 text-sm">
          <p className="font-medium">Welcome email</p>
          <p className="text-muted-foreground">welcome-2026.html, 48 KB</p>
        </div>
      </LoadingOverlay>
      <Button variant="outline" size="sm" onClick={() => setProgress(0)} disabled={progress !== null}>
        Upload template
      </Button>
    </div>
  )
}
```

## Accessibility

**Semantics.** While `loading`, the wrapper is `aria-busy="true"` and its content `inert`: hidden from assistive technology, out of the tab order, and not clickable. A `role="status"` region, always on the page, announces the label (or the provider's `loading`) when loading starts. The spinner and label on the mask are decorative copies of that status.

**Labels.** Write the label as what is happening ("Saving the mailer…"). Without one, the provider's `loading` string is announced.

**Focus.** If focus was inside when loading started (the Save button that started it), it returns there when loading ends. With `fullScreen`, every other part of the page is inert, and the page does not scroll, while it loads.

**Known limits.**

- It does not time out: end `loading` yourself, also when the work fails.
- A region keeps its size and place; the mask covers exactly the wrapper.
- With `fullScreen`, what is on the page when loading starts is made inert; an element added to `<body>` later (a dialog opened from code) is not.
- When two full-screen overlays overlap, focus goes back to where it was only if the first one to start is the last to end.

### Keyboard

| Key | Behaviour |
| --- | --- |

## API

### LoadingOverlay

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `fullScreen` | `boolean` | `false` | Covers the whole window instead of the content, makes the rest of the page inert and stops it scrolling. |
| `indicator` | `ReactNode` |  | Something to show in place of the spinner, such as a `ProgressCircle`. |
| `label` | `string` |  | Text beside the spinner, announced politely when loading starts ("Saving the mailer…"). |
| `loading` | `boolean` | `false` | Blocks the content and shows the mask. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="loading-overlay-portal"` (LoadingOverlay), and `data-state`, `data-loading`.

**Provider strings:** `loading` (`BooleanUIProvider`'s `strings`).

## Theming

The mask is the `--mask` token (40 % black, 60 % in dark) with the region's corners; the spinner sits on a small `--popover` card with the overlay shadow. It fades in and out with the overlay motion tokens.

| Token | Used for |
| --- | --- |
| `--mask` | background |
| `--popover` | background |
| `--popover-foreground` | text |
