Global operation
The same scrim that covers between sections learns a second reason to cover, and looks different for each — one instance, one state machine, two callers.
The context
One provider, mounted once
Plain React context, nothing framework-specific: a hook holding whether an operation is pending, and a run that wraps any promise. Mounted at the root next to AppScrim, so any page — this one included — can reach the same instance.
type GlobalOperation = {
isPending: boolean;
run: (operation: () => Promise<unknown>) => Promise<void>;
};
const GlobalOperationContext = createContext<GlobalOperation | null>(null);
export const GlobalOperationProvider = ({ children }: { children?: ReactNode }) => {
const [isPending, setIsPending] = useState(false);
const run = useCallback(async (operation: () => Promise<unknown>) => {
setIsPending(true);
try {
await operation();
} finally {
setIsPending(false);
}
}, []);
return <GlobalOperationContext value={{ isPending, run }}>{children}</GlobalOperationContext>;
};
export const useGlobalOperation = () => {
const value = useContext(GlobalOperationContext);
if (!value) throw new Error("useGlobalOperation was read outside its provider.");
return value;
};<GlobalOperationProvider>
<AppScrim />
<Outlet />
</GlobalOperationProvider>The scrim
Two reasons, one instance
No second Scrim. isPending is only ever combined into the same isLoading, variant, durationTime and holdTime the route transition already drives — an operation just briefly owns the decision.
const { isPending } = useGlobalOperation();
const isLoading = isPending || isEnteringSection;
const currentMode = isPending
? { key: "overlay", variant: "overlay", durationTime: OVERLAY_DURATION_MS, holdTime: OVERLAY_HOLD_MS }
: { key: section?.prefix ?? "", variant: section?.prefix, durationTime: DURATION_MS, holdTime: HOLD_MS };
const [mode, setMode] = useState(currentMode);
if (isLoading && mode.key !== currentMode.key) setMode(currentMode);
<Scrim until={appReady} isLoading={isLoading} variant={mode.variant} durationTime={mode.durationTime} holdTime={mode.holdTime}>
{mode.key === "overlay" ? <Spinner /> : <SplashContent />}
</Scrim>mode is why the veil does not flash the section title the instant an operation ends: it only follows currentMode while isLoading is true, and freezes the moment it turns false. Without it, the ternaries above would recompute on that same render and hand the closing scrim the section's variant instead of the overlay's — visible mid-exit, because the layer is still covering when it happens.
If both reasons were ever true together, isPending wins — the ternaries read left to right. That is a choice this app made, not something the library could make for it: it has no idea what either reason means.
A variant with no direction
overlay is a variant like any other, it just never touches translate — so it inherits 0 from the base rule and only opacity animates.
[data-scrim-variant="overlay"] {
background: rgb(0 0 0 / 0.5);
}
[data-scrim-variant="overlay"]:not([data-scrim-open]) {
opacity: 0;
}Try it
The real veil, not a copy
This button calls the same run a real operation would — clicking it covers the entire site with the overlay variant, using the actual AppScrim mounted at the root, not a demo instance confined to this page.
const { isPending, run } = useGlobalOperation();
<Button disabled={isPending} onClickBlur={() => run(() => delay(2500))}>
<Button.Label label={isPending ? "Running…" : "Simulate operation"} />
</Button>