Loading Overlay
A scrim that freezes one region or the whole viewport while work is in flight — delay-gated so short requests never flash, min-duration held so it never blinks out, and inert underneath so Tab cannot walk into the frozen form.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/loading-overlay.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "LoadingOverlay" component (React 19 +
react-dom createPortal; no other dependencies). It is a scrim that covers one
region — or the whole viewport — while work is in flight.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props:
- loading: boolean (required) — whether work is in flight. NOT the same thing
as visibility; the two timings below sit in between.
- label = string (default "Loading…") — rendered under the indicator and used
as the announcement. An empty string renders no text node and the layer
falls back to aria-label="Loading".
- variant = "overlay" | "blur" | "skeleton-fade" (default "overlay")
- scope = "container" | "fullscreen" (default "container")
- delay = number ms (default 200) — wait this long before showing.
- minDuration = number ms (default 400) — once shown, stay up at least this long.
- spinner?: ReactNode — replaces the built-in ring, rendered inside an
aria-hidden wrapper.
- children?: ReactNode — optional extra content under the label (a Cancel
button, a hint). Render it rather than dropping it on the floor: the layer
is never inert itself, so anything focusable you put here still works while
everything underneath is frozen.
- blocking = boolean (default true) — swallow pointer events AND keyboard focus.
- className merged last through cn(); remaining props spread on the layer.
- Clamp delay and minDuration with a helper: non-finite or negative collapses to
0. A NaN timeout fires immediately and a negative one is silently 0, so
clamping is what keeps a mistyped prop from turning into a stuck scrim.
- Return null while not visible: idle cost is one boolean, and because
visibility can only be raised inside a timer callback the server always
renders nothing, so there is no hydration branch to reconcile.
Behavior — the two timings ARE the component
- One effect, two timers, no synchronous setState in an effect body:
- loading && hidden -> setTimeout(delay) then show and record shownAt.
If loading drops before it fires, the cleanup clears it and nothing was
ever mounted: a 120ms save must not flash a scrim.
- !loading && shown -> setTimeout(minDuration - (now - shownAt)) then hide,
so a scrim that did appear never blinks straight back out.
- loading rising again during that wait clears the hide timer and keeps the
scrim up; shownAt is deliberately not reset, because the minimum was
already served by the time already spent visible.
- Both timers are cleared on unmount and on every dependency change.
- scope="container": the layer is absolute inset-0 and the CONSUMER'S PARENT
must be position: relative — the parent is the covered region. That parent
gets aria-busy="true" while the scrim is up (restore the previous value on
cleanup, do not blindly remove the attribute).
- scope="fullscreen": createPortal to document.body with position: fixed
inset-0. Portalling is what makes it immune to an ancestor with
overflow: hidden or a transform, which would otherwise clip a fixed layer.
- blocking=true is the whole point, and a scrim alone does NOT deliver it. A
translucent layer eats clicks but not Tab, so the user can keyboard straight
into the form you just told them is frozen. Mark what is underneath inert:
- container: every SIBLING of the layer inside the parent. Apply it to the
siblings, not to the parent, or the scrim ends up inside the inert subtree
and its own status text disappears from the accessibility tree.
- fullscreen: every other child of document.body (skip nodes carrying the
component's own data attribute, since the portal is a body child too).
- Skip elements that are already inert and remember which ones you touched,
so cleanup never clears somebody else's inert. The list is a snapshot taken
when the scrim appears; siblings mounted mid-wait are not retro-frozen,
which is fine for the "freeze this form while it saves" case and worth
knowing if your region swaps its children while loading.
- Cost to document, not to hide: inert needs a modern engine (Chrome 102+,
Safari 15.5+, Firefox 112+) and React 19 to pass it as a boolean prop. Here
it is set through setAttribute on nodes React does not own, so no React
version constraint applies to the consumer; on an engine without inert
support the scrim still blocks the pointer, it just stops blocking Tab.
- blocking=false: the layer takes pointer-events-none, nothing is made inert
and the scroll lock is skipped. It is a decorative "something is happening
here" wash and the content underneath stays fully usable.
- Fullscreen + blocking locks body scroll, and both the reentrancy count and
the pre-lock snapshot live on `document.body` as data attributes —
`body.dataset.zyScrollLocks` (count), `.zyScrollLockOverflow`,
`.zyScrollLockPadding` — never in module-level variables. The 0 → 1 edge
snapshots body's current inline `overflow` / `paddingRight` and freezes;
later locks only increment; only the 1 → 0 release writes the snapshot back
and deletes all three attributes. Why not a module variable: each component
is installed as its own copy, so one page runs several independent copies of
this same lock (this overlay, a drawer, a popover), each with a private
counter that cannot see the others. Nest two and the outer one restores ""
on close while the inner one later writes back the "hidden" it recorded as
the original — the page is unscrollable for good, with no overlay left on
screen to explain it. A body attribute is the one namespace independent
copies already share, so keep the three names byte-identical wherever this
code is pasted. The fullscreen page freeze is also counted (0 → 1 marks the
other body children inert, 1 → 0 unmarks them), but that one can stay a
module-level counter: it skips nodes that are ALREADY inert and removes only
the ones it marked, so a second copy of this code nests harmlessly instead
of stealing somebody else's attribute. The scroll lock has no such
self-identifying state — one shared `overflow` value on one shared element —
which is why it, and not the freeze, has to escalate to the DOM.
- Scrollbar compensation is MEASURED, not predicted: read
`document.documentElement.clientWidth`, set `overflow: hidden`, read it
again, and add the positive difference to body's computed `paddingRight`.
The `innerWidth - clientWidth` shortcut is wrong on any page with
`scrollbar-gutter: stable` — the gutter is permanent, no width is reclaimed,
and padding ~15px anyway shoves the frozen region LEFT at the exact moment
the scrim appears, which is doubly obvious here because the content
underneath is still visible through it. Measuring is also a no-op on macOS
overlay scrollbars, so a Mac-only test proves nothing about this branch.
- Marking an ancestor inert blurs whatever was focused, so capture
document.activeElement before freezing and refocus it on release — but only
if it is still isConnected and focus actually fell to <body>, so you never
yank focus back from somewhere the user has since moved to.
Rendering & styling
- Semantic tokens only, no hardcoded colors:
- overlay: bg-background/75 — dims but keeps the region readable.
- blur: bg-background/40 + backdrop-blur-sm — pushes half-stale rows out of
focus so they cannot be misread mid-refresh.
- skeleton-fade: bg-card/90 plus an absolutely positioned shimmer band
(bg-gradient-to-r from-transparent via-foreground/10 to-transparent) sliding
translateX(-100% -> 100%) — reads as "this area is being rebuilt".
- Container layer: rounded-[inherit] so the scrim picks up the card's radius
instead of drawing square corners over rounded content; overflow-hidden so
the shimmer is clipped by that radius.
- Ship every keyframe in ONE React 19 hoisted <style href precedence="medium">
tag: it is deduped by href, so ten overlays emit one copy and the consumer
edits no Tailwind config.
- Layer is role="status" with aria-live="polite" (the label is the announcement)
while aria-busy lives on the covered region; the indicator and the shimmer are
aria-hidden so assistive tech hears one message, not three decorations.
- prefers-reduced-motion: the fade-in and the shimmer sweep are dropped, and the
ring swaps its rotation for a slow opacity pulse — the indicator must still
read as "working", never freeze into what looks like a broken ring. Express
this as animation utilities plus motion-reduce: variants of the same property,
so the media query wins by source order without any specificity tricks.
- Label is centred, text-balance and max-w-[min(28rem,100%)]: a wait is a good
place for a sentence, and a sentence must not spill out of the region.
Customization levers
- Timings are the main dial: delay 0 for a manually toggled scrim you want
instantly, 400-600ms for a chatty endpoint you expect to be fast; minDuration
down to 0 if you would rather have honesty than smoothness. Keep
delay < minDuration or the gate stops buying you anything.
- Add a variant by adding one entry to the scrim class map — the state machine,
the freeze and the a11y wiring are variant-independent.
- Swap the indicator with the spinner prop (a brand mark, a progress ring, a
bar row) instead of editing the component; it is already wrapped for you.
- Density: the layer is a flex column with gap-3 and p-6 — drop to gap-2 p-3 for
a compact card, or pass items-start pt-10 to sit the indicator near the top of
a tall region instead of dead centre.
- Tone: keep the scrim on bg-background / bg-card, or make it brand-forward with
bg-primary/10; the indicator uses border-t-primary and inherits your token.
- Stacking: z-10 for container, z-50 for fullscreen. If the covered region has
its own stacking contexts (sticky table headers, a popover) raise the
container layer through className rather than editing the component.
- Exit animation is deliberately absent (the layer unmounts) because
minDuration already removes the flicker a fade-out would be hiding; if you
want one, keep it mounted one extra tick and animate opacity to 0.Concepts
- Delay gate —
loadingis not visibility. A scrim that appears for 80ms reads as a glitch, so showing is deferred bydelay(200ms) and the pending timer is simply cleared if the request wins the race: fast requests produce no scrim at all, which is the single biggest quality difference between this and a bare conditional render. - Min-duration hold — the mirror problem. Once a scrim is up, hiding it instantly makes it blink; the hide waits out the remainder of
minDuration(400ms) measured from the moment it actually appeared, so the shortest possible appearance is a deliberate one. - Real freeze vs fake freeze — a translucent layer stops the mouse and nothing else. Tab still walks into the "frozen" form underneath, and that is the most common defect in this class of component.
blockingmarks what is underneathinert, applied to the layer's siblings (never its ancestor, which would swallow the layer's own status text along with everything else). - Container scope needs a positioning context — the layer is
absolute inset-0, so the covered region is whatever parent hasposition: relative. That parent is also what carriesaria-busy, which is why the region is described by the element the consumer already owns rather than by a wrapper this component would have to introduce. - Portal beats z-index — a fullscreen layer rendered in place is at the mercy of any ancestor with
overflow: hiddenor atransform, which turnsposition: fixedinto "fixed relative to that ancestor". Portalling tobodysidesteps the whole class of clipping bugs. - Counted global side effects, counted in the right place — the body scroll lock keeps its count and its saved
overflow/paddingRightondocument.bodyas data attributes, because each component here is installed as a separate copy: a module-level counter is invisible to the other copy of the same lock running inside a drawer or popover on the same page, and two blind counters end up restoring each other's values (""first, then the"hidden"the second one thought was original) and freeze the page for good. Theinertpage freeze keeps a plain module counter instead — it can afford to, since it only marks nodes that were not already inert and only unmarks those, so copies nest without stepping on one another. - Measured scrollbar compensation — the padding applied while locked is the observed
clientWidthdifference acrossoverflow: hidden, neverinnerWidth - clientWidth: on a page withscrollbar-gutter: stablenothing is reclaimed, so the predicted value pads phantom width and shifts the frozen region left the moment the scrim appears. It also measures 0 under macOS overlay scrollbars, which is why this defect survives Mac-only testing. - Reduced-motion still means loading — under
prefers-reduced-motionthe fade-in and the shimmer sweep are removed, and the ring trades rotation for a slow opacity pulse rather than freezing into a shape that looks like a rendering bug.