模态对话框
对话框在逐渐变暗的背景上浮起放大,离开时原路返回,全部基于原生 dialog 元素。
用原生 <dialog> 元素制作带 CSS 进出场过渡的模态框。用 showModal() 打开,自动获得焦点锁定、Escape 关闭和顶层显示。关闭状态设为 opacity 0、translateY(12px)、scale(0.96),[open] 状态为 opacity 1、无 transform。opacity、transform、overlay 和 display 过渡 260ms,缓动 cubic-bezier(0.16, 1, 0.3, 1),并设置 transition-behavior: allow-discrete,让退场也能动画。用 @starting-style 给 [open] 状态设置起始值,让进场有动画。::backdrop 同样处理,淡入深色遮罩并加一点背景模糊。点击“取消”、“删除”或点击遮罩时关闭。在 prefers-reduced-motion 下保留淡入淡出,去掉位移。对话框标题为“要删除这个项目吗?”。
源代码
'use client'
import { useId, useRef } from 'react'
import s from './demo.module.css'
export default function ModalDialog({ labels }: { labels: Record<string, string> }) {
const ref = useRef<HTMLDialogElement>(null)
const titleId = useId()
const close = () => ref.current?.close()
return (
<>
<button type="button" className={s.open} onClick={() => ref.current?.showModal()}>
{labels.open}
</button>
<dialog
ref={ref}
className={s.dialog}
aria-labelledby={titleId}
onClick={(e) => e.target === e.currentTarget && close()}
>
<div className={s.body}>
<h2 id={titleId} className={s.title}>
{labels.title}
</h2>
<p className={s.text}>{labels.body}</p>
<div className={s.actions}>
<button type="button" className={s.ghost} onClick={close} autoFocus>
{labels.cancel}
</button>
<button type="button" className={s.danger} onClick={close}>
{labels.confirm}
</button>
</div>
</div>
</dialog>
</>
)
}
.open {
height: 44px;
padding: 0 20px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
cursor: pointer;
}
.open:hover { border-color: var(--stage-accent); }
.open:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.dialog {
width: min(360px, calc(100vw - 32px));
padding: 0;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-md);
background: var(--stage-surface);
color: var(--stage-ink);
box-shadow: 0 24px 60px oklch(0% 0 0 / 0.45);
opacity: 0;
transform: translateY(12px) scale(0.96);
transition:
opacity 260ms var(--ease-out),
transform 260ms var(--ease-out),
overlay 260ms allow-discrete,
display 260ms allow-discrete;
}
.dialog[open] {
opacity: 1;
transform: none;
}
@starting-style {
.dialog[open] {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
}
.dialog::backdrop {
background: oklch(0% 0 0 / 0);
backdrop-filter: blur(0);
transition:
background-color 260ms var(--ease-out),
backdrop-filter 260ms var(--ease-out),
overlay 260ms allow-discrete,
display 260ms allow-discrete;
}
.dialog[open]::backdrop {
background: oklch(0% 0 0 / 0.55);
backdrop-filter: blur(3px);
}
@starting-style {
.dialog[open]::backdrop {
background: oklch(0% 0 0 / 0);
backdrop-filter: blur(0);
}
}
.body {
display: grid;
gap: 10px;
padding: 22px;
}
.title {
margin: 0;
font: 600 var(--text-lg) / 1.3 var(--font-display);
color: var(--stage-ink);
}
.text {
margin: 0;
font: 400 var(--text-sm) / 1.5 var(--font-body);
color: var(--stage-muted);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.ghost, .danger {
height: 38px;
padding: 0 16px;
border-radius: var(--radius-sm);
font: 500 var(--text-sm) / 1 var(--font-body);
cursor: pointer;
}
.ghost { border: 1px solid var(--stage-rule); background: transparent; color: var(--stage-ink); }
.danger { border: 0; background: var(--stage-accent); color: var(--stage-accent-ink); }
.ghost:focus-visible, .danger:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.dialog, .dialog[open] { transform: none; }
@starting-style {
.dialog[open] { transform: none; }
}
}