ComponentsMisc
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"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.
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. 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 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.
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.
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.
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.
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 isaria-busy="true"and its contentinert: hidden from assistive technology, out of the tab order, and not clickable. Arole="status"region, always on the page, announces the label (or the provider'sloading) 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
loadingstring 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
loadingyourself, 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.
- It does not time out: 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.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--mask | background |
--popover | background |
--popover-foreground | text |