Backdrop
The scrim beneath every overlay. Opacity, blur, click-through, scroll locking, and what happens when two overlays collide.
Also called Scrim, Overlay, Dim Layer — in this system all of them are Backdrop.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
api-gateway
Deployment 4021 finished in 42 seconds across three regions.
The page behind should read as paused, not gone. Try 0.42 and 0.72.
The opacity is theme-dependent
A white page needs far less dimming to read as inactive. One value for both themes leaves one of them wrong.
Paused, not gone
At 90% the context that told the user what they were confirming is unreadable. At 20% the overlay does not read as the subject at all.
Blur is separate from opacity
A small blur lets you use less dimming for the same sense of separation — the page reads as out of focus rather than darkened.
When two overlays stack
A dialog opened from a drawer gets its own scrim, and the result is double-dimmed. Either reuse one backdrop or accept that the second overlay is the only readable thing on screen.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
api-gateway
Deployment 4021 finished in 42 seconds across three regions.
A full-viewport layer beneath the overlay, dimming and blurring the page and absorbing the dismissing click.
- Coverageposition: fixed; inset: 0
The whole viewport, including under a fixed header. A scrim that stops at the header leaves an interactive strip the user can still reach.
- Opacity72% dark, 42% light
Theme-dependent, not a constant. A white page reads as inactive at far less dimming than a dark one.
- Blur2px, optional
Separate from opacity. A little blur buys the same separation with less dimming, so the context stays readable.
- Z-indexOne below its overlay
The pair moves together. A scrim and an overlay in different stacking contexts is how you get a dialog behind its own backdrop.
- EntranceFade, 180ms
Slightly slower than the overlay’s own entrance, so the dimming reads as settling behind rather than arriving with it.
- Click targetThe whole layer
Dismisses by default. Suppress it only where dismissal would lose work — and then the user needs another obvious way out.
- Inert backgroundNot visual at all
The scrim dims; inert is what actually removes the page from the tab order. Without it the modal is one Tab press from being escaped invisibly.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-layer-scrim | — | The scrim fill — theme-aware by definition |
| --ds-surface-overlay | — | The surface that sits on top of it |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e5 | — | The overlay above the scrim |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-normal | Fade in and out | |
| --ease-standard | — | Fade curve |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Min width | When to use |
|---|---|---|---|
| Coverage | 100dvh | 100vw | Fixed to the viewport, dvh so a mobile URL bar collapsing does not reveal a strip. |
| Dark opacity | 72% | — | Enough that a dark page reads as paused rather than merely tinted. |
| Light opacity | 42% | — | A white page needs far less. The same value as dark looks like a rendering fault. |
| Blur | 2px | — | Optional. Buys separation without extra dimming, at a real compositing cost. |
| Fade | 180ms | — | Slightly slower than the overlay it sits behind. |
appRoot.inert = true
// not just opacityconst w = innerWidth − documentElement.clientWidth
body.style.paddingRight = `${w}px`<Portal>
<Scrim /> <Dialog />
</Portal>Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The scrim itself has no contrast requirement — it is decoration. What matters is that the overlay above it still reaches its own ratios, which a very dark scrim can quietly help with and a very light one cannot.
- Text remaining visible through the scrim is not required to meet contrast, because it is explicitly unavailable. It must not look available either.
- In forced-colors mode the scrim is typically removed entirely, so the overlay must be distinguishable by its border alone.
Keyboard
| Esc | Dismisses, matching an outside click. If clicking away closes it, Escape must too. |
| Tab | Cycles within the overlay only. The scrim is never focusable and the background is inert. |
| Click | On the scrim, dismisses. Suppress only where work would be lost, and then provide an obvious alternative. |
Screen readers
- A scrim should be entirely silent. If a screen reader announces anything about it, it has a role or a label it should not have.
- The background must be genuinely removed, not merely dimmed. A user who can still arrow into the page behind has no way to know the dialog is open.
- Restore the background when the overlay closes, including on an error path. A page left permanently inert is unusable and looks like a crash.
Focus & touch
- The scrim is never focusable. Focus moves into the overlay on open and returns to the trigger on close. The background being inert is what makes the focus trap reliable — a trap implemented purely in JavaScript will eventually be escaped by a browser-specific tab order.
- Use 100dvh rather than 100vh, or a collapsing mobile URL bar reveals an undimmed strip at the bottom. Scroll locking on iOS needs position: fixed with the scroll offset restored on close — overflow: hidden alone does not hold. Backdrop blur is expensive on mid-range phones; measure before shipping it as a default.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-hidden="true" | The scrim element | It is pure decoration and must never be announced. |
| inert | The application root | The real mechanism. Removes the background from the tab order and the accessibility tree in one attribute. |
| aria-hidden="true" | The application root | The fallback where inert is unsupported. It hides from screen readers but does not remove tab stops — you still need a focus trap. |
| aria-modal="true" | The overlay | On the dialog, not the scrim. It tells assistive tech the background is unavailable. |
Example usage
1import { Backdrop } from '@/ui/Overlay'23// The scrim and its overlay live in ONE portal, so nothing in the app can4// slide a z-index between them.5<Portal>6 <Backdrop open={open} onClose={close} />7 <Dialog open={open} onClose={close} />8</Portal>910// The scrim is visual. This is what actually makes the page unavailable.11React.useEffect(() => {12 if (!open) return13 const root = document.getElementById('app')!14 root.inert = true15 return () => { root.inert = false } // including on an error path16}, [open])1718// Scroll lock without the sideways jump. Removing the scrollbar shifts the19// page by its width — pad the body by exactly that amount.20function useScrollLock(locked: boolean) {21 React.useEffect(() => {22 if (!locked) return23 const width = window.innerWidth - document.documentElement.clientWidth24 const prev = {25 overflow: document.body.style.overflow,26 padding: document.body.style.paddingRight,27 }28 document.body.style.overflow = 'hidden'29 document.body.style.paddingRight = `${width}px`30 return () => {31 document.body.style.overflow = prev.overflow32 document.body.style.paddingRight = prev.padding33 }34 }, [locked])35}3637// Two overlays: reuse one scrim rather than stacking two to 92%.38const scrimVisible = dialogOpen || drawerOpenFramework-free HTML
<!-- The page. inert is the mechanism; the scrim is the appearance. -->
<div id="app" inert>
<h1>api-gateway</h1>
<button type="button">Deploy</button>
</div>
<!-- Both in one stacking context. -->
<div class="ds-overlay-root">
<!-- Pure decoration: never announced, never focusable. -->
<div class="ds-backdrop" aria-hidden="true"></div>
<div role="dialog" aria-modal="true" aria-labelledby="t">
<h2 id="t">Roll back?</h2>
</div>
</div>CSS
.ds-backdrop {
position: fixed;
/* dvh, not vh: a collapsing mobile URL bar otherwise reveals an
undimmed strip at the bottom. */
inset: 0;
block-size: 100dvh;
z-index: 95; /* exactly one below its overlay */
background: var(--ds-layer-scrim);
animation: fade-in 180ms var(--ease-standard) both;
}
/* Theme-dependent, not a constant. A white page reads as inactive at far
less dimming than a dark one. */
[data-theme='dark'] { --ds-layer-scrim: rgb(0 0 0 / 0.72); }
[data-theme='light'] { --ds-layer-scrim: rgb(0 0 0 / 0.42); }
/* Buys separation without extra dimming — at a real compositing cost.
Never animate it. */
.ds-backdrop--blur { backdrop-filter: blur(2px); }
/* The scrim and the overlay must share one stacking context, or a z-index
elsewhere in the app can slide between them. */
.ds-overlay-root { position: fixed; inset: 0; z-index: 95; }
@media (prefers-reduced-motion: reduce) {
.ds-backdrop { animation: none; }
}
/* Scrims are typically removed here, so the overlay must stand on its
border alone. */
@media (forced-colors: active) {
.ds-backdrop { background: transparent; }
[role='dialog'] { border: 1px solid; }
}Component API
Backdrop
| Prop | Type | Default | Description |
|---|---|---|---|
| open* | boolean | — | Controlled. Mount only while open — a permanently mounted scrim at opacity 0 still composites. |
| onClose | () => void | — | Called on click. Omit only where dismissal would lose work, and then provide an obvious alternative. |
| opacity | number | theme token | Overrides the theme value. Rarely correct — the token already accounts for light and dark. |
| blur | number | 0 | Pixels of backdrop blur. Expensive on mid-range devices; measure before defaulting it on. |
| lockScroll | boolean | true | Locks body scroll and compensates for the scrollbar width so the page does not jump. |
Professional tips
- Reuse one backdrop instance across every overlay in the app. Two stacked scrims is the most common way this component goes wrong.
- Fade the scrim slightly slower than the overlay it sits behind, so the dimming reads as settling rather than arriving.
- If clicking the scrim would lose unsaved work, do not silently suppress it — confirm instead. A dialog that ignores an outside click with no feedback reads as frozen.
- Match Escape to outside-click behaviour exactly. If one dismisses, both must.
- Test with the page scrolled halfway down. Scroll-lock bugs are invisible at the top of the page and obvious anywhere else.
Performance
- backdrop-filter forces a new compositing layer over everything beneath it. On a complex page and a mid-range phone this is measurable, and it is why blur is opt-in here.
- Animate opacity only. Animating the blur radius re-composites the whole layer on every frame.
- Unmount the scrim when closed. A permanently mounted element at opacity 0 still participates in compositing.
- On iOS, overflow: hidden on the body does not reliably lock scroll — position: fixed with the offset restored on close is the version that works.
Common mistakes
- Relying on the scrim for accessibility, leaving the background reachable by Tab.
- Scroll lock with no scrollbar compensation, so the page jumps sideways on open.
- One opacity for both themes, leaving the light theme looking untouched.
- Two stacked scrims when a dialog opens from a drawer.
- 100vh instead of 100dvh, leaving an undimmed strip on mobile.
- The scrim and overlay in different stacking contexts, producing a dialog behind its own backdrop.
- Animating backdrop-filter, dropping frames on the devices that can least afford it.
- Leaving the background inert after an error closes the overlay.
Real-world recommendations
- Users click the scrim to dismiss constantly — it is the most-used dismissal path in most products, ahead of both Escape and the close button. Suppressing it needs a real reason.
- The scroll-position jump on open is the bug users notice most and report least, because it is hard to describe. Fix it once in the shared component.
- Blur looks excellent in a design review on a fast laptop and costs real frames on a three-year-old Android. Measure on the hardware your users actually have.
- If a product has several overlay types, one shared backdrop with a reference count is worth building early. Retrofitting it after four components have their own is far harder.