---
title: "Modal layer"
description: "The layer under every modal surface. It dims the window behind what it presents, makes everything there inert, moves focus in, keeps it there and gives it back, and asks to close on Escape, the platform's back or a press on the dim. Restyle it once and every modal surface follows."
url: "https://tanrei.chnotchy.com/components/modal-layer/"
last_updated: 2026-10-23T21:17:12.000Z
---

# Modal layer

> The layer under every modal surface. It dims the window behind what it presents, makes everything there inert, moves focus in, keeps it there and gives it back, and asks to close on Escape, the platform's back or a press on the dim. Restyle it once and every modal surface follows.

> [!NOTE]
> **Advanced**
>
> Most products place [Bottom sheet](https://tanrei.chnotchy.com/components/bottom-sheet/), [DateTimePicker](https://tanrei.chnotchy.com/components/date-time-picker/), [Dialog](https://tanrei.chnotchy.com/components/dialog/), [Image preview](https://tanrei.chnotchy.com/components/image-preview/), [Navigation drawer](https://tanrei.chnotchy.com/components/navigation-drawer/), [Search](https://tanrei.chnotchy.com/components/search/), [Side sheet](https://tanrei.chnotchy.com/components/side-sheet/), which this component is a part of.

## Overview

Every modal surface has the same layer under it. It dims the window behind the surface, so the page reads as set aside. It makes everything there inert: nothing behind can be pressed, focused or read by a screen reader, and the page does not scroll. It moves focus into the surface, keeps it there while the surface is open, and gives it back when the surface closes. And it asks to close when the person presses Escape, uses the platform’s back, or presses the dim.

The modal layer is that layer, as a component of its own. The [dialog](https://tanrei.chnotchy.com/components/dialog/), the [side sheet](https://tanrei.chnotchy.com/components/side-sheet/) and the [bottom sheet](https://tanrei.chnotchy.com/components/bottom-sheet/) when they are modal, the modal [navigation drawer](https://tanrei.chnotchy.com/components/navigation-drawer/), the [image preview](https://tanrei.chnotchy.com/components/image-preview/), the modal [date-time picker](https://tanrei.chnotchy.com/components/date-time-picker/) and the [search](https://tanrei.chnotchy.com/components/search/)’s modal views are all built from it. Each of them follows its rules instead of having rules of its own.

It is an **advanced** component. You meet it in two ways:

- **To restyle every modal surface at once.** The layer’s tokens, `comp.modal-layer.*`, are what each of those components’ scrim and motion tokens point to. Set them in your product’s layer and every modal surface changes together: the dim’s color and opacity, a blur of what lies behind, and how long the layer takes to come and go.
- **To build a modal surface Tanrei does not have**: a command palette, a takeover of the whole window, a viewer of your own. Put your surface inside the layer and it behaves like every other modal surface of your product.

For a decision, use the dialog. For a panel, use the modal side sheet or bottom sheet. They already name themselves, place their actions and give a way out.

On the web it is `<tanrei-modal-layer>`. The other platforms have not implemented it yet.

### Usage

The layer is controlled. Set `open` to show it. When the person asks to close it, it emits `close` with the reason (`escape`, `back` or `scrim`) and stays open: you set `open` back to false. Give it a `label`, which is the name a screen reader announces.

The layer draws nothing of its own but the dim. Your content keeps its own color, shape and padding, and the layer centres it in the window at its own size. Place it elsewhere with the platform’s own means (on the web, `::part(container)`).

**Web**

```html
<tanrei-button id="open-palette" variant="text">Commands</tanrei-button>

<tanrei-modal-layer label="Commands">
  <div class="palette">
    <!-- autofocus marks initial focus. Without it, focus goes to the layer's container. -->
    <input autofocus aria-label="Search commands">
    <ul>…</ul>
  </div>
</tanrei-modal-layer>

<style>
  /* Place the container yourself: here, near the top of the window. */
  tanrei-modal-layer::part(container) { margin-block-start: 15vh; }
</style>

<script type="module">
  import '@tanrei/web/components/modal-layer';
  const layer = document.querySelector('tanrei-modal-layer');
  document.querySelector('#open-palette').addEventListener('click', () => (layer.open = true));
  // Escape, back or a press on the dim. The layer never closes by itself.
  layer.addEventListener('close', (event) => {
    console.log(event.detail.reason); // 'escape', 'back' or 'scrim'
    layer.open = false;
  });
</script>
```

**React**

```tsx
// An advanced component — a building block of others — is imported from the advanced entry.
import { ModalLayer } from '@tanrei/react/advanced';

<ModalLayer open={open} label="Commands" onClose={() => setOpen(false)}>
  <CommandPalette />
</ModalLayer>
```

**SwiftUI**

Not implemented yet. The planned form is a view modifier: `.tanreiModalLayer(isPresented:, accessibilityLabel:, onClose:) { … }`.

**Compose**

Not implemented yet. The planned form is `TanreiModalLayer(visible, onCloseRequest, contentDescription) { … }`.

**Flutter**

Not implemented yet. The planned form is `TanreiModalLayer(open:, onClose:, semanticLabel:, child: …)`.

**React Native**

Not implemented yet. The planned form is `<ModalLayer visible onClose accessibilityLabel>…</ModalLayer>`.

### Restyle every modal surface

A product sets the layer’s tokens in its layer file, and every modal surface follows:

```json
{
  "tokens": {
    "tanrei.comp.modal-layer.scrim.color": { "$value": "{tanrei.sys.color.scrim}" },
    "tanrei.comp.modal-layer.scrim.blur": { "$value": { "value": 8, "unit": "px" }, "reason": "Our modal surfaces frost what lies behind them." }
  }
}
```

The dim’s opacity is the alpha of its color: change the color, or the system’s `scrim` role, to make it lighter or darker. To set one surface apart, set that surface’s own token instead (`comp.dialog.scrim.color`, `comp.dialog.motion.enter-duration`). It points to the layer’s until you do.

## Specs

- Category: Containment
- Level: base
- Listing: advanced
- Used in: Dialog, Navigation drawer, Search, Bottom sheet, Side sheet, DateTimePicker, Image preview
- Page: https://tanrei.chnotchy.com/components/modal-layer/
- Spec: https://github.com/chnotchy/tanrei/blob/main/specs/modal-layer.spec.json

### Availability

| Design system | Has it | Notes |
| --- | --- | --- |
| Tanrei (`tanrei`) | Yes | — |
| Cupertino (Apple HIG-inspired) (`cupertino`) | Yes | — |
| M3 Expressive-like (Material 3 Expressive-inspired) (`m3-expressive-like`) | Yes | — |
| M3-like (Material 3-inspired) (`m3-like`) | Yes | — |

### Platforms

- Web: the element `<tanrei-modal-layer>`, registered by importing `@tanrei/web` (or only its own module, `@tanrei/web/components/modal-layer`).
- React: `ModalLayer` from `@tanrei/react/advanced`.

| Platform | Name | Status |
| --- | --- | --- |
| Web | `tanrei-modal-layer` | alpha |
| SwiftUI | `tanreiModalLayer` | planned |
| Jetpack Compose | `TanreiModalLayer` | planned |
| Flutter | `TanreiModalLayer` | planned |
| React Native | `ModalLayer` | planned |

Names a platform gives in its own idiom:

| Spec | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
| --- | --- | --- | --- | --- | --- |
| `open` (prop) | `open` | `isPresented` | `visible` | `open` | `visible` |
| `dismissible` (prop) | `closedby` | `dismissible` | `dismissible` | `dismissible` | `dismissible` |
| `label` (prop) | `label` | `accessibilityLabel` | `contentDescription` | `semanticLabel` | `accessibilityLabel` |
| `close` (event) | `close` | `onClose` | `onCloseRequest` | `onClose` | `onClose` |

Statuses: `planned` — In the inventory. Nothing shippable exists yet. `lagging` — An implementation of an earlier spec exists; it has not caught up with the current one. `alpha` — Implemented with tests. The API may still change. `beta` — Feature complete and at parity. Only fixes are expected. `stable` — Covered by semantic versioning. `deprecated` — Still shipped; scheduled for removal.

### Anatomy

- `scrim` (optional): The dim over the whole window behind the container, to every edge and under every system inset. It blocks presses and, while the layer is dismissible, a press on it asks the layer to close. Not drawn while scrim is false.
- `container`: The box the content is in, placed in the window's safe area. It draws nothing of its own — no color, edge, shadow, shape or padding — takes focus when nothing in the content is marked for it, and is the modal dialog assistive technology reads.

### Props

| Prop | Type | Default | Web attribute | Description |
| --- | --- | --- | --- | --- |
| `open` | `boolean` | `false` | `open` | Whether the layer is presented. The product opens it, and closes it in answer to close (or from its content's own controls); on SwiftUI, where it is a binding, the layer writes false itself. |
| `dismissible` | `boolean` | `true` | — | Whether Escape, the platform's back and a press on the scrim ask the layer to close (MDL-6). Set it to false only when the person must finish with one of the content's own controls, and give the content one. |
| `scrim` | `boolean` | `true` | `no-scrim` (turns it off) | Whether the layer dims what lies behind its container. False for content that covers the whole window with a fill of its own (a full-window preview): the layer then draws nothing behind it and still blocks everything there (MDL-2). |
| `label` | `string` | `""` | `label` | Names the container for assistive technology ("Export photos"): the modal dialog's name. Never drawn. Required. |

### Events

| Event | Description |
| --- | --- |
| `close` | The layer asks to be closed: Escape, the platform's back or a press on the scrim. Carries which of these it was, named escape, back or scrim (in the platform's casing). Emitted only while dismissible is true. |

### Slots

| Slot | Description |
| --- | --- |
| `default` | The content: the surface the product presents modally (a panel, a card, a view over the whole window). The layer places its container (MDL-11) and draws, styles and sizes nothing of the content. A fixture's content string is plain text. |

### States

`enabled`

### Accessibility

- Role: `dialog`
- Labeling: The container is a modal dialog named by label; everything outside it is hidden from assistive technology while the layer is open. The scrim is not exposed. A component built from the modal layer names the dialog by its own headline or label, and the dialog may be an alert dialog.
- Minimum target size: yes

| Keys | Action |
| --- | --- |
| Tab / Shift+Tab | Move focus through the focusable elements in the container, wrapping from the last to the first and back; focus never leaves the layer. |
| Escape | Asks the layer to close (close, reason escape) while dismissible is true; with stacked layers, only the topmost. A key an element inside takes first goes no further. |

- The modal layer is what every modal surface of Tanrei has in common: the dialog, the modal side sheet, the modal bottom sheet, the modal navigation drawer, the image preview, the modal date-time picker and the search's modal views are each built from it and cite its rules. Restyle their scrim and their tempo once, through comp.modal-layer.*.
- Use it alone for a modal surface Tanrei does not have — a command palette, a takeover over the whole window, a viewer of your own. For a decision use the Dialog; for a panel, the modal side sheet or bottom sheet: they already hold focus, name themselves and place their actions.
- Give the content a way out of its own (a Close, Cancel or Done button). Escape, back and the scrim are not found by everyone, and with dismissible false they do nothing.
- The layer has no target of its own; what the product puts in it meets the minimum target itself.
- Inert, focus moved in, kept and given back: the WAI-ARIA Authoring Practices' modal dialog pattern and HTML's modal dialog. The web presents the container with a modal \<dialog> element (showModal gives the top layer and inertness); the other platforms present it in a window or a route of its own with the system's dim switched off, so that every platform draws the layer's own scrim.

### Behaviour

Requirements in EARS form, each with its stable id.

- **MDL-1** WHEN open becomes true THE modal layer SHALL present its container above everything in its window — every region, every overlay not opened from inside it (MDL-9) and every modal layer opened before it (MDL-8) — and, WHILE scrim is true, SHALL draw its scrim over the whole window behind the container, to every edge and under every system inset, filled with comp.modal-layer.scrim.color (its alpha is the dim) and, WHILE comp.modal-layer.scrim.blur is above 0, with what lies behind it blurred by a Gaussian blur of that radius (the radius of CSS's blur()); WHERE the platform cannot blur what lies behind a window it SHALL draw the color alone. WHILE scrim is false it SHALL draw nothing behind the container and SHALL still block what is behind as MDL-2 says. A component built from the modal layer draws the scrim from its own scrim tokens, which alias these two (comp.dialog.scrim.color and comp.dialog.scrim.blur, and so on).
- **MDL-2** WHILE open is true THE modal layer SHALL make everything outside its container inert — not focusable, not pressable, hidden from assistive technology — and SHALL keep the content behind it from scrolling without moving it (where a scroll bar takes room, that room stays while the scroll is held), so that a press, a scroll or a key anywhere outside the container reaches nothing behind it; WHEN it closes THE modal layer SHALL restore what is behind to what it was before: its scroll position, and what was focusable, pressable and exposed.
- **MDL-3** WHEN open changes from false to true, and WHEN the layer is mounted with open true, THE modal layer SHALL move focus to the element in its content that the product marked for initial focus (the platform's own mark, named in the platform notes) or, without one, to the element the component built from it names (the navigation drawer's selected destination, NVD-19) or else to its container, so that assistive technology reads its name first and the next Tab reaches the first focusable element. Focus SHALL move when the layer opens, not when its motion ends.
- **MDL-4** WHILE open is true THE modal layer SHALL keep focus inside its container: Tab from the last focusable element in it SHALL move to the first, Shift+Tab from the first (or from the container itself) SHALL move to the last, the container SHALL keep focus WHILE nothing in it is focusable, and screen-reader navigation SHALL NOT leave it.
- **MDL-5** WHEN the layer closes THE modal layer SHALL return focus to the element that had it when the layer opened or, where that element no longer exists, cannot take focus or is inert, to the platform's default for a closed overlay; focus SHALL move when the layer closes, not when its motion ends.
- **MDL-6** WHILE dismissible is true and open is true, WHEN the user presses Escape, uses the platform's back (Android's back gesture or button, the iOS accessibility escape gesture) or presses the scrim — a press that starts and ends outside the container — THE modal layer SHALL emit close exactly once with the reason escape, back or scrim, and SHALL stay open until open becomes false: the product sets it from close (through the binding where open is one, SwiftUI), unless the component built from the modal layer closes itself as its own spec says (DLG-5, NVD-20). Escape SHALL reach the layer only after everything inside its container has heard it: a key an element inside takes (a field clearing its text, a menu closing) SHALL NOT also ask the layer to close. WHILE dismissible is false THE modal layer SHALL ignore these inputs and emit nothing, and a press on the scrim SHALL still reach nothing behind it.
- **MDL-7** WHEN the layer opens THE modal layer SHALL fade its scrim and its container in from transparent over comp.modal-layer.motion.enter-duration with comp.modal-layer.motion.enter-easing, and WHEN it closes SHALL fade both out over comp.modal-layer.motion.exit-duration with comp.modal-layer.motion.exit-easing, keeping them drawn until the fade ends. A component built from the modal layer SHALL fade the scrim in step with its own surface, over its own enter and exit motion, and moves its surface as its own spec says (a dialog grows, a sheet slides). The enter and exit tokens of a surface that takes the modal role (a dialog, a modal picker, an image preview) alias these four, so that one value sets the tempo of every such surface of a product; a sheet (a bottom sheet, a side sheet, a modal navigation drawer) takes the sheet role (tanrei.sys.motion.role.appear.sheet.enter and .exit) instead, so that one value sets the tempo of every sheet. WHERE motion is reduced THE modal layer SHALL fade without moving or growing anything, or SHALL appear and disappear at once WHERE the component built from it says so or on a platform whose reduced-motion setting removes every animation (Android's).
- **MDL-8** WHEN a modal layer opens while another is open THE modal layer SHALL stack above it with its own scrim, make the first one inert with the rest of the window, and WHEN it closes return focus to the element inside the first one that had it; Escape, the platform's back and a press on the scrim SHALL reach only the topmost layer.
- **MDL-9** WHEN an overlay that is not modal opens from an element inside the container — a menu, a tooltip, a popover, a select's list, a docked picker — THE modal layer SHALL let it be presented above the layer and operated while the rest of the window is inert, and SHALL NOT count a press inside it as a press on the scrim: the overlay belongs to the layer as POP-25 says.
- **MDL-10** THE modal layer SHALL expose its container as a modal dialog named by label, everything outside it being hidden (MDL-2); a component built from it names the dialog by its own headline or label and may expose it as an alert dialog (DLG-14). WHILE label is empty THE modal layer SHALL still open, and in debug builds SHALL report that it has no accessible name.
- **MDL-11** THE modal layer SHALL draw nothing of its own but the scrim: its container has no color, edge, shadow, shape or padding and hands its content no color or text style, so that plain text given as content takes what the window around it gives. It SHALL lay the container out at the size of its content, centred in the window's safe area and above the on-screen keyboard while one is shown, and never larger than that area, the content scrolling inside it where it is larger; the product places or sizes the container otherwise in the platform's own way (named in the platform notes). A component built from the modal layer places and draws its own surface (DLG-7, SSH-7, BSH-6, IMP-1).
- **MDL-12** THE modal layer SHALL take its layout direction from where it is used and SHALL have no direction setting of its own; it SHALL mirror nothing itself, and its content follows the direction.
- **MDL-13** WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the host, by the attribute or by the ARIA property that reflects it, THE modal layer SHALL move it to the data-aria- attribute of the same name, so that the host is no second node with its name, description or state, and SHALL expose it on its dialog (MDL-10) — an id reference by the elements it names in the host's tree or a tree around it, a description after its own. WHILE label is not empty it is the dialog's name, over a name given so; WHILE label is empty, a name given so is the dialog's name and SHALL NOT be reported as having none in debug builds. WHEN the product removes the aria- attribute or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaDescribedByElements…) to null, THE modal layer SHALL take it off that element.

### Tokens

As written in the token documents (DTCG): an alias in braces names the token it takes its value from, which each design system resolves. On the web each token is a CSS custom property: `--tanrei` and the name after `tanrei`, joined by hyphens (`tanrei.comp.modal-layer.…` → `--tanrei-comp-modal-layer-…`).

#### `tanrei.comp.modal-layer`

| Token | Type | Value |
| --- | --- | --- |
| `comp.modal-layer.scrim.color` | `color` | `{tanrei.sys.color.scrim}` |
| `comp.modal-layer.scrim.blur` | `dimension` | `0px` |
| `comp.modal-layer.motion.enter-duration` | `duration` | `{tanrei.sys.motion.role.appear.modal.enter}` |
| `comp.modal-layer.motion.enter-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.enter}` |
| `comp.modal-layer.motion.exit-duration` | `duration` | `{tanrei.sys.motion.role.appear.modal.exit}` |
| `comp.modal-layer.motion.exit-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.exit}` |

## Guidelines

### What the layer does and what it leaves to you

| The layer | Your content |
| --- | --- |
| Dims the window behind it (or draws nothing, with `scrim` off), to every edge. | Its own color, shape, shadow and padding. The layer adds none. |
| Makes everything behind inert and keeps the page from scrolling. | Its own scrolling, when it is taller than the window. |
| Moves focus in, keeps it there, gives it back on closing. | Marks the element that should take focus first, where there is one. |
| Asks to close on Escape, the platform’s back and a press on the dim. | A way out of its own: a Close, Cancel or Done button. |
| Centres the container in the window at its content’s size. | Where it goes instead, if not the centre. |

### Without the dim

Turn `scrim` off for content that covers the whole window with a fill of its own, such as a viewer over a dark background. There is nothing left to dim. The layer still blocks everything behind, and a press outside your content still asks to close.

### Do

- Name the layer with `label`. A screen reader announces it when focus moves in.
- Put a visible way out in the content. Not everyone knows Escape, and back is not on every device.
- Change a product’s modal surfaces through `comp.modal-layer.*`, so that they stay alike.

### Don’t

- Don’t build a dialog or a sheet from the bare layer. Use the dialog or the sheet, which already give a headline, actions and their places.
- Don’t turn `dismissible` off without a way out in the content. The person would have no way to leave.
- Don’t use it for something that should not block the page. A popover, a menu or a snackbar leave the page usable.

## Accessibility

### What the component handles

- The container is a modal dialog, named by `label`. Everything outside it is hidden from assistive technology while it is open.
- Focus moves into the layer when it opens, even when it is written open. It goes to the element you marked, or else to the container, so the name is read first.
- Tab and Shift+Tab wrap inside the layer. Focus never reaches the page behind.
- Closing gives focus back to where it was. If that element is gone, focus goes where the platform puts it.
- Escape asks to close only once everything inside has heard it. A field that clears its text on Escape, or a menu that closes, keeps the layer open.
- A layer opened from inside another stacks above it. Only the top one answers Escape, back and its dim.

### What you need to do

- Give a `label` that says what the surface is for.
- Mark the first element to focus when a form or a search field is the reason for the surface.
- Give the content a visible control that closes it, and keep `dismissible` on unless that control exists.
