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
| prop | type | default | |
|---|---|---|---|
until? | () => unknown | undefined | awaited once on mount; the scrim covers from the first paint until it resolves |
open? | boolean | false | keeps the scrim covering while true |
variant? | string | undefined | mirrored to data-scrim-variant, so one scrim can be styled differently per destination |
children? | ReactNode | — | rendered 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-scrim | always present; marks the layer |
data-scrim-open | the scrim is covering |
data-scrim-instant | the current state must apply without animating |
data-scrim-ready | until has resolved; latched, it never goes back |
data-scrim-variant | mirrors 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.
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.
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.
[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.
[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.
[data-scrim-ready] .title {
opacity: 1;
}