底部弹出面板
面板从底部边缘升起,盖在变暗的屏幕上,就像手机 App 呈现快捷选项的方式。
分享到
用 React 和 CSS 制作移动端风格的底部面板。遮罩覆盖整个屏幕,初始 opacity 0、pointer-events none;面板位于底部边缘之下,transform: translateY(100%)。打开时遮罩在 240ms 内淡入到 opacity 1,面板在 380ms 内滑到 translateY(0),缓动为 cubic-bezier(0.32, 0.72, 0, 1),起步快、收尾柔和,接近原生面板。面板顶部为圆角,带一个小拖动条、标题和一组操作。点击遮罩、按 Escape 或点击任一操作时关闭。打开时把焦点移入面板,关闭时把焦点还给触发按钮,并设置 aria-modal 和 role="dialog"。在 prefers-reduced-motion 下只淡入淡出,不做滑动。面板标题为“分享到”。
源代码
'use client'
import { useEffect, useId, useRef, useState } from 'react'
import s from './demo.module.css'
export default function BottomSheet({ labels }: { labels: Record<string, string> }) {
const [open, setOpen] = useState(false)
const sheet = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const titleId = useId()
useEffect(() => {
if (open) sheet.current?.focus()
}, [open])
const close = () => {
setOpen(false)
trigger.current?.focus()
}
return (
<div className={s.screen} data-open={open || undefined} onKeyDown={(e) => e.key === 'Escape' && open && close()}>
<button ref={trigger} type="button" className={s.pill} onClick={() => setOpen(true)}>
{labels.open}
</button>
<div className={s.scrim} onClick={close} aria-hidden="true" />
<div
ref={sheet}
className={s.sheet}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
inert={!open}
>
<span className={s.handle} aria-hidden="true" />
<p id={titleId} className={s.title}>
{labels.title}
</p>
{[labels.a, labels.b, labels.c].map((t) => (
<button key={t} type="button" className={s.action} onClick={close}>
{t}
</button>
))}
</div>
</div>
)
}
.screen {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.pill {
height: 40px;
padding: 0 18px;
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);
white-space: nowrap;
cursor: pointer;
}
.pill:hover { border-color: var(--stage-accent); }
.pill:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.scrim {
position: absolute;
inset: 0;
background: oklch(0% 0 0 / 0.55);
opacity: 0;
pointer-events: none;
transition: opacity 240ms var(--ease-out);
}
.sheet {
position: absolute;
left: 50%;
bottom: 0;
display: grid;
gap: 2px;
width: min(320px, 100%);
padding: 8px 12px 14px;
border-radius: 18px 18px 0 0;
background: var(--stage-surface);
box-shadow: 0 -10px 30px oklch(0% 0 0 / 0.35);
outline: none;
transform: translate(-50%, 100%);
transition: transform 380ms cubic-bezier(0.32, 0.72, 0, 1);
}
.screen[data-open] .scrim { opacity: 1; pointer-events: auto; }
.screen[data-open] .sheet { transform: translate(-50%, 0); }
.handle {
justify-self: center;
width: 36px;
height: 4px;
margin-bottom: 6px;
border-radius: 2px;
background: var(--stage-rule);
}
.title {
margin: 0 0 4px;
padding: 0 8px;
font: 600 var(--text-sm) / 1.4 var(--font-display);
color: var(--stage-ink);
}
.action {
height: 38px;
padding: 0 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--stage-ink);
font: 400 var(--text-sm) / 1 var(--font-body);
text-align: left;
cursor: pointer;
}
.action:hover { background: var(--stage-bg); }
.action:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) {
.sheet { transition: opacity 200ms; transform: translate(-50%, 0); opacity: 0; }
.screen[data-open] .sheet { opacity: 1; }
}