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