modal · neumorphic · 灵感来源:Neumorphism(外圈浮起、内圈压入,共用一组同色双阴影)
按 ESC 或点击背景关闭
Neumorphic Dialog (APG) · 拟态模态 · 模态 / Neumorphic
做一个 Neumorphic Dialog (APG)(拟态模态):
- role=dialog + aria-modal + aria-labelledby
- 焦点陷阱:Tab 在内部循环,关闭后焦点归还触发元素
- Esc 关闭 + 点遮罩关闭;面板无边框无模糊
- 面板外双阴影浮起,内容区 inset 双阴影压入
用这些 CSS 变量:--color-accent --color-bg --color-text --color-text-muted --fw-semibold --neuro-bg --neuro-dark --neuro-light --neuro-shadow --shadow-scrim
灵感参考:Neumorphism(外圈浮起、内圈压入,共用一组同色双阴影)--color-accent --color-bg --color-text --color-text-muted --fw-semibold --neuro-bg --neuro-dark --neuro-light --neuro-shadow --shadow-scrim
焦点 / 禁用
(尚未接进整页场景)
/r/modal--neumorphic.json
免外壳的 markdown 版:/llms/components/modal--neumorphic.md--neuro-bg + --neuro-shadow 向外出;限制:弹在深色遮罩上时同色阴影几乎不可见,面板主要靠"遮罩变暗 + 内容凹槽"识别——这是拟态轴的固有代价。
import { type ReactNode, useEffect, useId, useRef } from 'react';
import { useOverlay } from '~/lib/overlay';
interface Props {
open: boolean;
onClose: () => void;
title?: string;
children?: ReactNode;
actions?: ReactNode;
}
/**
* 拟态模态:与玻璃模态同一份 props,
* 面板从遮罩里"浮"出来:同色底 + 大号双阴影;内容区是压进去的凹槽。
*/
export function NeumorphicModal({
open,
onClose,
title = '拟态模态',
children,
actions,
}: Props) {
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const { mounted, entering, leaving } = useOverlay(open);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
// APG Dialog:焦点进模态 → Tab 在里面循环 → 关闭后焦点归还给触发元素
useEffect(() => {
if (!open) return;
const restore = document.activeElement as HTMLElement | null;
const focusables = () =>
Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
) ?? [],
);
focusables()[0]?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const list = focusables();
if (list.length === 0) return;
const first = list[0];
const last = list[list.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
restore?.focus();
};
}, [open]);
if (!mounted) return null;
return (
<div
role="dialog"
aria-modal="true"
className="overlay-scrim fixed inset-0 z-50 flex items-center justify-center"
data-leaving={leaving || undefined}
style={{ background: 'var(--shadow-scrim)' }}
onClick={onClose}
>
<div
ref={panelRef}
aria-labelledby={titleId}
onClick={(e) => e.stopPropagation()}
data-entering={entering || undefined}
data-leaving={leaving || undefined}
className="
overlay-panel
w-[480px] max-w-[92vw]
rounded-3xl
bg-[var(--neuro-bg)]
shadow-[var(--neuro-shadow)]
p-6
"
>
<div className="flex items-start justify-between mb-4">
<h3
id={titleId}
className="text-xl"
style={{ fontWeight: 'var(--fw-semibold)' }}
>
{title}
</h3>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="
press-feedback
size-8 rounded-xl
bg-[var(--neuro-bg)]
shadow-[3px_3px_7px_var(--neuro-dark),-3px_-3px_7px_var(--neuro-light)]
text-[var(--color-text-muted)] hover:text-[var(--color-text)]
"
>
✕
</button>
</div>
<div className="
p-4 rounded-2xl mb-5
bg-[var(--neuro-bg)]
shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]
text-sm text-[var(--color-text)] min-h-[60px]
">
{children ?? '模态内容区'}
</div>
<div className="flex items-center justify-end gap-2">
{actions ?? (
<button
type="button"
onClick={onClose}
className="
press-feedback
px-4 py-2 rounded-lg text-sm
bg-[var(--color-accent)] text-[var(--color-bg)] font-medium
"
>
确定
</button>
)}
</div>
</div>
</div>
);
}
src/components/modal/neumorphic/