Modal
ui overlaydialog
Portal-basierter Dialog mit Header/Body/Footer-Slots, Größen (sm/md/lg/xl/full), Escape- und Backdrop-Klick zum Schließen. Header kann einen eigenen titleNode (z. B. Tabs) oder headerActions statt des Schließen-X tragen.
Abhängigkeiten
Barrierefreiheit
- role="dialog", aria-modal="true", aria-label aus `title`.
- Escape schließt; Klick auf den Backdrop schließt (Klick im Dialog nicht).
- Schließen-Button-Label über `closeLabel` (i18n).
- Hinweis: Fokus-Trap/Restore ist bewusst nicht enthalten — bei strengen a11y-Anforderungen ergänzen oder React Aria <Dialog> erwägen.
Beispiele
Mit Footer
<Modal
open={open}
onClose={() => setOpen(false)}
title="Lizenz aktivieren"
footer={<Button variant="primary">OK</Button>}
>
Inhalt…
</Modal> Genutzte Tokens · 15
--bg--border--elev-overlay--fg--font-display--muted--radius-lg--radius-sm--space-2--space-3--space-4--space-5--surface--text-lg--tracking-display
Quelldateien
components/ui/Modal.tsx tsx
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import styles from './Modal.module.css';
/** Stack aller offenen Modals (Mount-Reihenfolge = Stapel-Reihenfolge).
* Escape darf nur das oberste schließen — sonst würde ein ESC ein Modal
* UND das darunterliegende Overlay (z. B. Einstellungen) zugleich schließen. */
const modalStack: object[] = [];
interface Props {
open: boolean;
onClose: () => void;
/** Accessible label for the dialog. Also used as the visible title unless
* `titleNode` is provided. */
title: string;
/** Optional custom render for the header title slot (e.g. tabs). When set,
* it replaces the default `<h2>{title}</h2>`. `title` is still used as
* `aria-label`. */
titleNode?: ReactNode;
/** Optional content rendered in the top-right of the header instead of the
* default close (×) button — e.g. inline Save/Cancel actions. When set,
* the close button is hidden. */
headerActions?: ReactNode;
/** Body-Inhalt. Der Body setzt bewusst nur Padding und KEINEN Abstand
* zwischen Geschwistern — mehrere Blöcke gehören in einen eigenen
* Container mit `gap`, sonst kleben sie aneinander. */
children: ReactNode;
footer?: ReactNode;
/** Vollflächige Ebene INNERHALB des Overlays, hinter dem Dialog — etwa eine
* Bild-Bühne beim Erststart-Assistenten. Sie liegt über der Abdunklung des
* Overlays (die malt hinter ihren Kindern) und bleibt vom `backdrop-filter`
* unberührt: was hier steht, ist scharf und ungedimmt. Wer Kontrast für den
* Dialog braucht, bringt ihn im Knoten selbst mit. */
backdrop?: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
/** Overrides the default body class entirely — use when the consumer needs
* to drop the default padding/scroll (e.g. for a full-bleed grid layout). */
bodyClassName?: string;
}
export function Modal({
open,
onClose,
title,
titleNode,
headerActions,
children,
footer,
backdrop,
size = 'md',
bodyClassName,
}: Props) {
// `onClose` über eine Ref lesen, damit instabile Callback-Identitäten den
// Effect (und damit die Stack-Position) nicht bei jedem Render neu anlegen.
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
});
useEffect(() => {
if (!open) return;
const token = {};
modalStack.push(token);
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && modalStack[modalStack.length - 1] === token) {
onCloseRef.current();
}
};
window.addEventListener('keydown', handler);
return () => {
modalStack.splice(modalStack.indexOf(token), 1);
window.removeEventListener('keydown', handler);
};
}, [open]);
if (!open) return null;
return createPortal(
<div
className={`${styles.overlay} ${size === 'full' ? styles.overlayFull : ''}`}
onMouseDown={onClose}
>
{backdrop && (
<div className={styles.backdrop} aria-hidden="true">
{backdrop}
</div>
)}
<div
className={`${styles.dialog} ${styles[size]}`}
role="dialog"
aria-modal="true"
aria-label={title}
onMouseDown={(e) => e.stopPropagation()}
>
<header className={`${styles.header} ${titleNode ? styles.headerTight : ''}`}>
{titleNode ?? <h2 className={styles.title}>{title}</h2>}
{headerActions ? (
<div className={styles.headerActions}>{headerActions}</div>
) : (
<button className={styles.close} onClick={onClose} aria-label="Schließen">
×
</button>
)}
</header>
<div className={bodyClassName ?? styles.body}>{children}</div>
{footer && <footer className={styles.footer}>{footer}</footer>}
</div>
</div>,
document.body,
);
}
components/ui/Modal.module.css css
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: grid;
place-items: center;
z-index: 100;
padding: var(--space-5);
backdrop-filter: blur(4px);
}
/* Bild-/Grafik-Bühne hinter dem Dialog (Prop `backdrop`). Sie füllt das
Overlay, fängt aber keine Klicks ab — der Klick landet auf dem Overlay und
schließt wie gewohnt. */
.backdrop {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.dialog {
/* Über der Bühne — sonst überdeckte sie den Dialog (gleiche Stapelebene). */
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--elev-overlay);
display: flex;
flex-direction: column;
max-height: 90vh;
width: 100%;
overflow: hidden;
}
.sm {
max-width: 420px;
}
.md {
max-width: 720px;
}
.lg {
max-width: 960px;
}
.xl {
max-width: 1280px;
}
.full {
max-width: none;
max-height: none;
width: 100%;
height: 100%;
}
.overlayFull {
padding: var(--space-4);
}
.header {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: var(--space-5);
min-height: 56px;
padding: var(--space-3) var(--space-5);
border-bottom: 1px solid var(--border);
}
.header > .title {
align-self: center;
}
/** Variant: header hosts a fill-height titleNode (e.g. tab bar). Vertical
* padding is removed so the titleNode's own visual edges (like an active-tab
* underline) can meet the header's bottom border cleanly. */
.headerTight {
padding-block: 0;
}
.title {
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 600;
letter-spacing: var(--tracking-display);
margin: 0;
}
.close {
align-self: center;
background: transparent;
border: 0;
font-size: 24px;
line-height: 1;
color: var(--muted);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.headerActions {
align-self: center;
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.close:hover {
background: var(--bg);
color: var(--fg);
}
.body {
padding: var(--space-5);
overflow-y: auto;
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--border);
background: var(--bg);
}
Holen
# MCP (Claude Code / Cursor)
get_component({ name: "modal" })
# Registry direkt
https://www.ekklesi.tools/design/registry/components/modal.json