Scrim

Renders a fixed, inert layer over the viewport and writes its state onto it as data attributes. What that state looks like is your stylesheet.

API

Props

proptypedefault
until?() => unknownundefinedawaited once on mount; the scrim covers from the first paint until it resolves
open?booleanfalsekeeps the scrim covering while true
variant?stringundefinedmirrored to data-scrim-variant, so one scrim can be styled differently per destination
children?ReactNoderendered inside the layer

Every other div prop is forwarded, so className, style and the rest land on the layer itself. The element is rendered inert and aria-hidden: it is decoration, never something to read or reach.

Attributes

These are the real surface. Props are how you drive the component; attributes are what you style, and what a change would break for consumers.

attribute
data-scrimalways present; marks the layer
data-scrim-openthe scrim is covering
data-scrim-instantthe current state must apply without animating
data-scrim-readyuntil has resolved; latched, it never goes back
data-scrim-variantmirrors the variant prop

Usage

Driving it

until covers the first load, open covers everything after it. They are separate because the first one ends on a signal — fonts, data, a minimum you chose — while the second is a boolean you already have.

tsx
const appReady = () => Promise.all([document.fonts.ready, delay(650)]);

<Scrim until={appReady} open={isEnteringSection} variant={section}>
  <SplashContent />
</Scrim>

Keep until stable, defined outside the render. It is awaited in an effect keyed on its identity, so a new function on every render would restart the wait.

tsx
const appReady = () => Promise.all([document.fonts.ready, delay(650)]);

What the package styles

Three rules, and none of them is a look. The layer covers the viewport, it is shown while open, and it applies instantly when told to. Duration, easing, direction and stacking are yours.

css
[data-scrim] {
  position: fixed;
  inset: 0;
  visibility: hidden;
}

[data-scrim][data-scrim-open] {
  visibility: visible;
}

[data-scrim][data-scrim-instant] {
  transition: none;
}

Details

Why the instant rule is a shorthand

data-scrim-instant resets transition wholesale rather than zeroing duration and delay. A rule of yours like the one below has the same specificity and comes later in the cascade, so it would win on transition-delay and the state would apply late instead of at once. Zeroing transition-property cannot be overridden that way.

css
[data-scrim][data-scrim-instant] {
  transition: none;
}

[data-scrim-variant="/settings"]:not([data-scrim-open]) {
  transition-delay: 650ms;
}

The entrance belongs to the variant that enters

Opening happens in two steps: the incoming variant is applied unanimated for one frame, then the scrim opens. Without it a transition would start from wherever the previous variant parked the element — and a variant that fades out leaves nothing for a variant that slides in to animate from.

That is also why the mechanism is transitions and not keyframes. Keyframes declare their own start and would make the step unnecessary, but they restart instead of reversing when a transition is interrupted mid-flight.

ready is a latch

data-scrim-ready appears when until resolves and never goes away, so it is safe to style anything that must stay revealed once the app has loaded — a title waiting on fonts, for instance, which should not hide again on the next route change.

css
[data-scrim-ready] .title {
  opacity: 1;
}