docs / core / loading-overlay

LoadingOverlay

a dimming scrim with a centered spinner, over its parent while it loads or over the whole page.

intensity · recedesrsc · client

provenance

shaped from sisu-plus

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

over a positioned panel or Card while its data loadscontain="viewport" for whole-page transitions, even over an open modalnot for button-level loading. Button has a status machinenot a Dialog scrim. it dims content, it does not trap focus

a11y

the spinner is a role="status" region announcing the label once · the visible caption is aria-hiddenjest-axe

dependencies

Spinner from the same package
live demo · try it out

content underneath, dimmed by the overlay.

Fetching courses
customize
variantspinner shape
sizespinner scale
toneaccent or inherited text color
labelannounced and shown as caption
blurbackdrop blur behind the scrim
usagecopy
import { LoadingOverlay } from "usva/primitives/loading-overlay";

<div className="relative">
  <LoadingOverlay label="Fetching courses" />
</div>

props

proptypedefaultnotes
contain"viewport" | "parent""parent"parent covers the nearest positioned ancestor and locks nothing. viewport locks body scroll, refcounted, and restores the exact overflow value it found.
labelstring"Loading"announced by the status region and repeated as a visible caption.
blurbooleantruebackdrop blur behind the scrim.
variant"ring" | "dots" | "bars" | "orbit""ring"forwarded to Spinner.
sizeSpinnerSize"lg"forwarded to Spinner.
toneSpinnerTone"accent"forwarded to Spinner.

get it

npx shadcn add https://usva.build/r/loading-overlay.jsoncopy
source · components/ui/loading-overlay.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  Spinner,
  type SpinnerSize,
  type SpinnerTone,
  type SpinnerVariant,
} from "./spinner";
import { useScrollLock } from "./use-scroll-lock";

export type OverlayContain = "viewport" | "parent";

export interface LoadingOverlayProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /**
   * `parent` covers the nearest positioned ancestor and locks nothing.
   * `viewport` covers the page and locks body scroll.
   */
  contain?: OverlayContain;
  label?: string;
  blur?: boolean;
  size?: SpinnerSize;
  variant?: SpinnerVariant;
  tone?: SpinnerTone;
}

/**
 * A scrim with a centered spinner.
 *
 * Defaults to `contain="parent"` because that variant locks nothing, and a
 * scroll lock is the one thing here that can reach outside the component and
 * break an unrelated modal.
 */
export const LoadingOverlay = React.forwardRef<
  HTMLDivElement,
  LoadingOverlayProps
>(
  (
    {
      className,
      contain = "parent",
      label,
      blur = true,
      size = "lg",
      variant = "ring",
      tone = "accent",
      ...props
    },
    ref,
  ) => {
    useScrollLock(contain === "viewport");

    return (
      <div
        ref={ref}
        data-testid="loading-overlay"
        data-contain={contain}
        className={cn(
          "z-overlay grid content-center place-items-center gap-3 bg-scrim",
          contain === "viewport" ? "fixed inset-0" : "absolute inset-0",
          blur && "backdrop-blur-sm",
          className,
        )}
        {...props}
      >
        <Spinner
          size={size}
          variant={variant}
          tone={tone}
          label={label ?? "Loading"}
        />
        {label != null && (
          <p
            aria-hidden="true"
            className="animate-reveal font-mono text-xs lowercase tracking-wide text-muted"
          >
            {label}
          </p>
        )}
      </div>
    );
  },
);
LoadingOverlay.displayName = "LoadingOverlay";
source · components/ui/use-scroll-lock.tsexactly what this command copiescopy
"use client";
import * as React from "react";

/**
 * Module-scoped so independently mounted lockers cooperate. A plain per-instance
 * effect cannot know another component still needs the page locked.
 */
let holders = 0;
let previousOverflow = "";

function acquire(): void {
  if (holders === 0) {
    previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
  }
  holders += 1;
}

function release(): void {
  holders -= 1;
  if (holders <= 0) {
    holders = 0;
    document.body.style.overflow = previousOverflow;
  }
}

/**
 * Locks body scroll while `active`, restoring the exact value that was there
 * before the first lock, not a hardcoded default.
 *
 * The naive version of this (`overflow = 'unset'` on cleanup) silently unlocks
 * the page when an overlay closes over a still-open modal, because the modal's
 * own lock is not consulted. Refcounting is what makes that safe.
 */
export function useScrollLock(active: boolean): void {
  React.useEffect(() => {
    if (!active) return;
    acquire();
    return release;
  }, [active]);
}