---
title: "模态层"
description: "每个模态表面下面的那一层。它把所呈现内容背后的窗口变暗，让那里的一切变为惰性；把焦点移进来、留在里面、关闭时交还；并在用户按下 Escape、使用平台的返回操作或按下变暗区域时请求关闭。只需调整它一次，所有模态表面都会随之改变。"
url: "https://tanrei.chnotchy.com/zh-cn/components/modal-layer/"
last_updated: 2026-10-23T21:17:12.000Z
---

# 模态层

> 每个模态表面下面的那一层。它把所呈现内容背后的窗口变暗，让那里的一切变为惰性；把焦点移进来、留在里面、关闭时交还；并在用户按下 Escape、使用平台的返回操作或按下变暗区域时请求关闭。只需调整它一次，所有模态表面都会随之改变。

> [!NOTE]
> **进阶**
>
> 大多数产品直接使用包含此组件的[Bottom sheet](https://tanrei.chnotchy.com/zh-cn/components/bottom-sheet/), [DateTimePicker](https://tanrei.chnotchy.com/zh-cn/components/date-time-picker/), [Dialog](https://tanrei.chnotchy.com/zh-cn/components/dialog/), [Image preview](https://tanrei.chnotchy.com/zh-cn/components/image-preview/), [Navigation drawer](https://tanrei.chnotchy.com/zh-cn/components/navigation-drawer/), [Search](https://tanrei.chnotchy.com/zh-cn/components/search/), [Side sheet](https://tanrei.chnotchy.com/zh-cn/components/side-sheet/)。

## 概览

每个模态表面下面都有同样的一层。它把表面背后的窗口变暗，让页面看起来被暂时搁在一旁。它让那里的一切变为惰性：背后的任何东西都无法按下、无法获得焦点，屏幕阅读器也读不到，页面也不会滚动。它把焦点移到表面里，在表面打开期间把焦点留在那里，并在表面关闭时把焦点交还。当用户按下 Escape、使用平台的返回操作或按下变暗区域时，它会请求关闭。

模态层就是这一层，被做成了一个独立的组件。[对话框](https://tanrei.chnotchy.com/zh-cn/components/dialog/)、模态形态的[侧边面板](https://tanrei.chnotchy.com/zh-cn/components/side-sheet/)和[底部面板](https://tanrei.chnotchy.com/zh-cn/components/bottom-sheet/)、模态的[导航抽屉](https://tanrei.chnotchy.com/zh-cn/components/navigation-drawer/)、[图片预览](https://tanrei.chnotchy.com/zh-cn/components/image-preview/)、模态的 [DateTimePicker](https://tanrei.chnotchy.com/zh-cn/components/date-time-picker/)，以及[搜索](https://tanrei.chnotchy.com/zh-cn/components/search/)的模态视图，都是用它构建的。它们都遵循它的规则，而不是各有一套规则。

它是一个**高级**组件。你会在两种情况下用到它：

- **一次调整所有模态表面的样式。** 上面这些组件的遮罩令牌和动效令牌，指向的都是这一层的令牌 `comp.modal-layer.*`。在产品的层里设置它们，所有模态表面就会一起改变：变暗区域的颜色和不透明度、对背后内容的模糊，以及这一层出现和消失所用的时长。
- **构建 Tanrei 没有的模态表面**：命令面板、占满整个窗口的界面、你自己的查看器。把你的表面放进这一层，它的行为就与产品中所有其他模态表面一致。

需要用户做决定，就用对话框。需要一块面板，就用模态的侧边面板或底部面板。它们已经会为自己命名、放置操作，并提供出路。

在 Web 上，它是 `<tanrei-modal-layer>`。其他平台尚未实现它。

### 用法

这一层是受控组件。把 `open` 设为 true 即可显示它。当用户请求关闭时，它会触发 `close` 并附上原因（`escape`、`back` 或 `scrim`），然后保持打开：由你把 `open` 设回 false。为它提供 `label`，这是屏幕阅读器朗读的名称。

这一层除了变暗区域之外，自己什么也不绘制。你的内容保留它自己的颜色、形状和内边距，这一层按内容本身的尺寸把它放在窗口中央。要放在别处，就用平台自己的方式（在 Web 上是 `::part(container)`）。

**Web**

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

<tanrei-modal-layer label="命令">
  <div class="palette">
    <!-- autofocus 标记初始焦点。没有它时，焦点移到这一层的容器上。 -->
    <input autofocus aria-label="搜索命令">
    <ul>…</ul>
  </div>
</tanrei-modal-layer>

<style>
  /* 容器由你自己放置：这里放在靠近窗口顶部的位置。 */
  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、返回操作或按下变暗区域。这一层从不自行关闭。
  layer.addEventListener('close', (event) => {
    console.log(event.detail.reason); // 'escape'、'back' 或 'scrim'
    layer.open = false;
  });
</script>
```

**React**

```tsx
// 进阶组件（用于构成其他组件的部件）从 advanced 入口导入。
import { ModalLayer } from '@tanrei/react/advanced';

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

**SwiftUI**

尚未实现。计划中的形式是一个视图修饰符：`.tanreiModalLayer(isPresented:, accessibilityLabel:, onClose:) { … }`。

**Compose**

尚未实现。计划中的形式是 `TanreiModalLayer(visible, onCloseRequest, contentDescription) { … }`。

**Flutter**

尚未实现。计划中的形式是 `TanreiModalLayer(open:, onClose:, semanticLabel:, child: …)`。

**React Native**

尚未实现。计划中的形式是 `<ModalLayer visible onClose accessibilityLabel>…</ModalLayer>`。

### 调整所有模态表面的样式

产品在自己的层文件里设置这一层的令牌，所有模态表面都会随之改变：

```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": "我们的模态表面会让背后的内容呈现磨砂效果。" }
  }
}
```

变暗区域的不透明度就是它颜色的 alpha 值：要让它更浅或更深，就改变这个颜色，或改变系统的 `scrim` 角色。要让某一个表面与众不同，就改为设置那个表面自己的令牌（`comp.dialog.scrim.color`、`comp.dialog.motion.enter-duration`）。在你设置之前，它指向的是这一层的令牌。

## 规格

- 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}` |

## 指南

### 这一层负责什么，留给你什么

| 这一层 | 你的内容 |
| --- | --- |
| 把背后的窗口一直到各个边缘都变暗（关闭 `scrim` 时则什么也不绘制）。 | 自己的颜色、形状、阴影和内边距。这一层一样也不添加。 |
| 让背后的一切变为惰性，并阻止页面滚动。 | 比窗口还高时，自己的滚动。 |
| 把焦点移进来，留在里面，关闭时交还。 | 如果有应当最先获得焦点的元素，就标记它。 |
| 在 Escape、平台的返回操作和按下变暗区域时请求关闭。 | 自己的出路：“关闭”“取消”或“完成”按钮。 |
| 按内容的尺寸把容器放在窗口中央。 | 如果不放在中央，要放在哪里。 |

### 不变暗时

如果内容用自己的填充覆盖整个窗口，例如深色背景上的查看器，就关闭 `scrim`。这时已经没有什么可以变暗了。这一层仍然挡住背后的一切，在内容之外按下仍然会请求关闭。

### 推荐

- 用 `label` 为这一层命名。焦点移进来时，屏幕阅读器会朗读它。
- 在内容里放一条看得见的出路。不是每个人都知道 Escape，也不是每台设备都有返回操作。
- 通过 `comp.modal-layer.*` 改变产品的模态表面，让它们保持一致。

### 避免

- 不要用光秃秃的一层来搭建对话框或面板。使用对话框或面板，它们已经提供了标题、操作以及操作的位置。
- 内容里没有出路时，不要关闭 `dismissible`。否则用户将无法离开。
- 不要把它用于不应挡住页面的东西。弹出框、菜单或消息条都让页面保持可用。

## 无障碍

### 组件负责的部分

- 容器是一个模态对话框，以 `label` 为名称。它打开期间，它之外的一切都对辅助技术隐藏。
- 这一层打开时，焦点会移进来，即使它在书写时就是打开的也是如此。焦点移到你标记的元素上，否则移到容器上，这样最先朗读的是名称。
- Tab 和 Shift+Tab 在这一层内循环。焦点永远不会到达背后的页面。
- 关闭时，焦点交还到原来的位置。如果那个元素已经不在了，焦点落在平台安排的位置。
- 只有在里面的一切都处理过 Escape 之后，Escape 才会请求关闭。如果有一个按 Escape 清空文本的输入框，或一个随之关闭的菜单，这一层就保持打开。
- 从另一层里面打开的层叠在它上面。只有最上面的那一层响应 Escape、返回操作和它的变暗区域。

### 需要你做的事

- 提供一个说明这个表面用途的 `label`。
- 当表单或搜索输入框是这个表面存在的理由时，标记最先获得焦点的元素。
- 为内容提供一个能关闭它的可见控件；除非有这样的控件，否则让 `dismissible` 保持开启。
