Modal Dialog für React
Ein Modal auf dem nativen Dialog-Element. Der Fokus bleibt drin, Escape und der Hintergrund schließen, und der Fokus kehrt zum Auslöser zurück.
Eine von 9 Components in Overlays
Ein Modal, das den Fokus selbst regelt
Ein Dialog unterbricht die Seite für eine Entscheidung oder eine kurze Aufgabe. Dieser baut auf dem nativen dialog-Element auf, der Browser hält also den Fokus darin, Escape schließt, die Seite dahinter ist gesperrt, und der Fokus kehrt zum Button zurück, der ihn geöffnet hat. Genau darauf verlassen sich Menschen mit Screenreader.
„Mit Formular“ passt zu kurzen Aufgaben wie einem Projekt einen neuen Namen geben oder eine Person einladen, mit der Hauptaktion unten und Absenden per Enter. Die Bestätigung fragt vor etwas, das sich nicht rückgängig machen lässt, nennt das Objekt im Titel und lässt den roten Button genau sagen, was passiert.
Halte einen Dialog bei einer Aufgabe und höchstens fünf Feldern. Schreib den Titel als Frage oder Aktion, nicht als Hinweis. Für Filter oder lange Details nimm ein Sheet, für eine Erfolgsmeldung einen Toast, ein Dialog sollte auf einer Seite die Ausnahme sein.
Details
Was Dialog mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Overlays
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/dialog.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 1 eigener Text
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle DialogProps aus components/ui/dialog.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<DialogPanelProps, 'onClose' | 'titleId' | 'descriptionId' | 'footer'> an.
triggeroptionalReact.ReactElementThe button that opens it
openoptionalbooleandefaultOpenoptionalbooleanonOpenChangeoptional(open: boolean) => voidfooteroptionalReact.ReactNode | ((close: () => void) => React.ReactNode)Footer actions. A function gets close().
roleoptional'dialog' | 'alertdialog'alertdialog: needs an answer, no backdrop click, no ×
Eingebaute Texte
Diese Texte schreibt Dialog selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 1 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| close | Englisch Close | Deutsch Schließen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Baut auf dem nativen dialog-Element auf. Der Browser hält den Fokus im Fenster und schließt es mit Escape.
- Setzt aria-describedby, aria-label und aria-labelledby, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle alert.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 391 Zeilen
'use client';
import { cloneElement, isValidElement, useCallback, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
const TEXTS = { en: { close: 'Close' }, de: { close: 'Schließen' } };
/**
* The dimmed layer behind modal surfaces, from the --scrim token (stronger in dark mode).
* It fades in with the dialog and out again before the dialog closes.
*/
export const scrim =
'backdrop:bg-scrim backdrop:backdrop-blur-[2px] backdrop:transition-opacity backdrop:duration-300 backdrop:ease-[var(--ease-kit)] starting:open:backdrop:opacity-0 data-[closing]:backdrop:opacity-0 data-[closing]:backdrop:duration-200 motion-reduce:backdrop:transition-none';
/*
* Parts of a panel rise one after another when a modal opens: header, body,
* footer. Only inside an open <dialog>, so a panel shown in place stays still.
*/
const CSS =
'.kitfx-dialog[open]:not([data-closing]) .kitfx-dialog-part{animation:kitfx-dialog-part .5s var(--ease-kit) both}' +
'.kitfx-dialog[open] .kitfx-dialog-part:nth-child(2){animation-delay:50ms}.kitfx-dialog[open] .kitfx-dialog-part:nth-child(3){animation-delay:100ms}' +
'.kitfx-dialog[open]:not([data-closing]) .kitfx-dialog-icon{animation:kitfx-dialog-icon .55s cubic-bezier(.34,1.56,.64,1) .08s both}' +
'@keyframes kitfx-dialog-part{from{opacity:0;transform:translateY(8px)}}@keyframes kitfx-dialog-icon{from{opacity:0;transform:scale(.6)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-dialog .kitfx-dialog-part,.kitfx-dialog .kitfx-dialog-icon{animation:none!important}}';
/** The keyframes for panel parts, rendered once into the head. Put kitfx-dialog on the <dialog>. */
export function DialogMotionStyle() {
return (
<style href="kitfx-dialog" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
/** pop: grows out of its trigger (slides up on phones). left, right, top, bottom: slides in from that edge. */
export type ModalMotion = 'pop' | 'left' | 'right' | 'top' | 'bottom';
const SPRING = 'cubic-bezier(0.34, 1.32, 0.64, 1)';
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const EXIT = 'cubic-bezier(0.4, 0, 0.9, 0.6)';
/* The element that opened the last modal, so the dialog can grow out of it */
let lastTrigger: { rect: DOMRect; at: number } | null = null;
/** Remember where a modal is opened from. withTrigger does this for you. */
export function rememberTrigger(el: Element | null) {
if (el) lastTrigger = { rect: el.getBoundingClientRect(), at: Date.now() };
}
const EDGE: Record<Exclude<ModalMotion, 'pop'>, string> = {
left: 'translateX(-100%)',
right: 'translateX(100%)',
top: 'translateY(-100%)',
bottom: 'translateY(100%)',
};
function enter(d: HTMLDialogElement, motion: ModalMotion, trigger: DOMRect | null): Animation[] {
if (motion !== 'pop') {
const rtl = getComputedStyle(d).direction === 'rtl';
const from = rtl && motion === 'left' ? EDGE.right : rtl && motion === 'right' ? EDGE.left : EDGE[motion];
return [d.animate([{ transform: from }, { transform: 'none' }], { duration: 480, easing: EASE })];
}
if (window.innerWidth < 640) {
return [d.animate([{ transform: 'translateY(32px)', opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: 440, easing: EASE })];
}
const r = d.getBoundingClientRect();
// Origin on the trigger, kept within reach so the scale never swings in from far away
const ox = trigger ? Math.min(Math.max(trigger.left + trigger.width / 2 - r.left, -r.width * 0.5), r.width * 1.5) : r.width / 2;
const oy = trigger ? Math.min(Math.max(trigger.top + trigger.height / 2 - r.top, -r.height * 0.5), r.height * 1.5) : r.height / 2;
const transformOrigin = `${round(ox)}px ${round(oy)}px`;
return [
d.animate([{ transform: 'scale(0.9)', transformOrigin }, { transform: 'none', transformOrigin }], { duration: 460, easing: SPRING }),
d.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 220, easing: EASE }),
];
}
function exit(d: HTMLDialogElement, motion: ModalMotion): Animation[] {
if (motion !== 'pop') {
const rtl = getComputedStyle(d).direction === 'rtl';
const to = rtl && motion === 'left' ? EDGE.right : rtl && motion === 'right' ? EDGE.left : EDGE[motion];
return [d.animate([{ transform: 'none' }, { transform: to }], { duration: 300, easing: EXIT, fill: 'forwards' })];
}
const phone = window.innerWidth < 640;
return [
d.animate([{ transform: 'none', opacity: 1 }, { transform: phone ? 'translateY(24px)' : 'scale(0.96)', opacity: 0 }], { duration: phone ? 240 : 180, easing: EXIT, fill: 'forwards' }),
];
}
function lockScroll() {
const html = document.documentElement;
const prev = html.style.overflow;
const gutter = window.innerWidth - html.clientWidth;
html.style.overflow = 'hidden';
if (gutter) html.style.paddingRight = `${gutter}px`;
return () => {
html.style.overflow = prev;
html.style.paddingRight = '';
};
}
/**
* Drives a native <dialog> as a modal: showModal() traps focus and makes
* the page inert, Escape closes, a click on the backdrop closes, the page
* stops scrolling, focus returns to the trigger. Opening plays an entrance
* from the trigger, closing plays the exit first and closes after it.
* Returns the props for the <dialog> and whether its content should render.
*/
export function useModalMotion(open: boolean, setOpen: (open: boolean) => void, { dismissible = true, motion = 'pop' }: { dismissible?: boolean; motion?: ModalMotion } = {}) {
const ref = useRef<HTMLDialogElement>(null);
const [present, setPresent] = useState(open);
const anims = useRef<Animation[]>([]);
const unlock = useRef<(() => void) | null>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
const stop = () => {
anims.current.forEach((a) => a.cancel());
anims.current = [];
};
if (open) {
setPresent(true);
delete d.dataset.closing;
stop();
if (!d.open) {
const recent = lastTrigger && Date.now() - lastTrigger.at < 1500 ? lastTrigger.rect : null;
const active = document.activeElement;
const trigger = recent ?? (active && active !== document.body ? active.getBoundingClientRect() : null);
lastTrigger = null;
d.showModal();
if (!unlock.current) unlock.current = lockScroll();
if (!prefersReducedMotion()) anims.current = enter(d, motion, trigger);
}
return;
}
const finish = () => {
stop();
delete d.dataset.closing;
if (d.open) d.close();
unlock.current?.();
unlock.current = null;
setPresent(false);
};
if (!d.open || prefersReducedMotion()) {
finish();
return;
}
d.dataset.closing = '';
stop();
anims.current = exit(d, motion);
let done = false;
const end = () => {
if (done) return;
done = true;
finish();
};
Promise.all(anims.current.map((a) => a.finished)).then(end, () => {});
const safety = window.setTimeout(end, 500);
return () => {
done = true;
window.clearTimeout(safety);
};
}, [open, motion]);
useEffect(
() => () => {
unlock.current?.();
unlock.current = null;
},
[],
);
const props = {
ref,
onCancel: (e: React.SyntheticEvent) => {
e.preventDefault();
if (dismissible) setOpen(false);
},
onClose: () => setOpen(false),
onMouseDown: (e: React.MouseEvent<HTMLDialogElement>) => {
// A press that starts on the backdrop, not a drag that ends there
if (dismissible && e.target === e.currentTarget) {
const r = e.currentTarget.getBoundingClientRect();
const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
if (!inside) setOpen(false);
}
},
};
return [props, open || present] as const;
}
/** The dialog props alone, for callers that render their content all the time. See useModalMotion. */
export function useModal(open: boolean, setOpen: (open: boolean) => void, options: { dismissible?: boolean; motion?: ModalMotion } = {}) {
return useModalMotion(open, setOpen, options)[0];
}
/** Open state that can be controlled or not, plus a trigger with aria wiring */
export function useDialogState(open?: boolean, defaultOpen?: boolean, onOpenChange?: (open: boolean) => void) {
const [inner, setInner] = useState(Boolean(defaultOpen));
const value = open ?? inner;
const set = useCallback(
(v: boolean) => {
setInner(v);
onOpenChange?.(v);
},
[onOpenChange],
);
return [value, set] as const;
}
export function withTrigger(trigger: React.ReactNode, open: boolean, setOpen: (v: boolean) => void, controls: string, haspopup: 'dialog' = 'dialog') {
if (!isValidElement<Record<string, unknown>>(trigger)) return trigger;
return cloneElement(trigger, {
'aria-haspopup': haspopup,
'aria-expanded': open,
'aria-controls': controls,
onClick: (e: React.MouseEvent) => {
(trigger.props.onClick as ((e: React.MouseEvent) => void) | undefined)?.(e);
rememberTrigger(e.currentTarget);
setOpen(true);
},
});
}
export type DialogSize = 'sm' | 'md' | 'lg' | 'xl';
const SIZE: Record<DialogSize, string> = { sm: 'sm:max-w-[24rem]', md: 'sm:max-w-[30rem]', lg: 'sm:max-w-[40rem]', xl: 'sm:max-w-[52rem]' };
export interface DialogPanelProps {
title: React.ReactNode;
description?: React.ReactNode;
/** An icon in a tile above the title, e.g. for confirmations */
icon?: IconName;
tone?: 'default' | 'destructive';
footer?: React.ReactNode;
onClose?: () => void;
/** Hide the × in the corner (alert dialogs that need an answer) */
hideClose?: boolean;
titleId?: string;
descriptionId?: string;
size?: DialogSize;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
children?: React.ReactNode;
}
/**
* The visible surface of a dialog. Use it inside <Dialog>, or alone to show
* a dialog in place, e.g. in a design review.
*/
export function DialogPanel({ title, description, icon, tone = 'default', footer, onClose, hideClose, titleId, descriptionId, size = 'md', labels, className, children }: DialogPanelProps) {
const t = useTexts(TEXTS, labels);
return (
<div className={cn('relative w-full rounded-2xl border border-border bg-popover text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]', SIZE[size], className)}>
<div className={cn('kitfx-dialog-part px-6 pt-6', !children && !footer && 'pb-6', icon && 'sm:flex sm:gap-4')}>
{icon && (
<span
className={cn(
'kitfx-dialog-icon mb-4 grid size-11 shrink-0 place-items-center rounded-full sm:mb-0',
tone === 'destructive' ? 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive' : 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface-2))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]',
)}
>
<Icon name={icon} size={20} />
</span>
)}
<div className={cn('min-w-0', !hideClose && 'pe-8')}>
<h2 id={titleId} className="text-[17px] font-semibold leading-snug tracking-[-0.015em] text-foreground">
{title}
</h2>
{description && (
<p id={descriptionId} className="mt-1.5 text-pretty text-sm leading-relaxed text-muted-foreground">
{description}
</p>
)}
</div>
</div>
{children && <div className={cn('kitfx-dialog-part px-6 pt-5', !footer && 'pb-6')}>{children}</div>}
{footer && <div className="kitfx-dialog-part mt-6 flex flex-col-reverse gap-2.5 rounded-b-2xl border-t border-border bg-surface px-6 py-4 sm:flex-row sm:justify-end [&>*]:max-sm:w-full">{footer}</div>}
{!hideClose && (
<button
type="button"
onClick={onClose}
aria-label={t.close}
className="group/close absolute end-3.5 top-3.5 grid size-9 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Icon name="x" size={17} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/close:rotate-90 motion-reduce:transition-none" />
</button>
)}
</div>
);
}
export interface DialogProps extends Omit<DialogPanelProps, 'onClose' | 'titleId' | 'descriptionId' | 'footer'> {
/** The button that opens it */
trigger?: React.ReactElement;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Footer actions. A function gets close(). */
footer?: React.ReactNode | ((close: () => void) => React.ReactNode);
/** alertdialog: needs an answer, no backdrop click, no × */
role?: 'dialog' | 'alertdialog';
}
/**
* A modal dialog on the native <dialog> element. It grows out of its trigger
* and shrinks back before it closes, the backdrop fades with it.
* <Dialog trigger={<Button>Invite</Button>} title="Invite people" footer={(close) => …}>…</Dialog>
*/
export function Dialog({ trigger, open: openProp, defaultOpen, onOpenChange, footer, role = 'dialog', hideClose, children, ...panel }: DialogProps) {
const [open, setOpen] = useDialogState(openProp, defaultOpen, onOpenChange);
const alert = role === 'alertdialog';
const [modal, present] = useModalMotion(open, setOpen, { dismissible: !alert });
const id = useId();
const close = () => setOpen(false);
return (
<>
{trigger && withTrigger(trigger, open, setOpen, id)}
<DialogMotionStyle />
<dialog
{...modal}
id={id}
role={alert ? 'alertdialog' : undefined}
aria-labelledby={`${id}-t`}
aria-describedby={panel.description ? `${id}-d` : undefined}
className={cn(
'kitfx-dialog m-auto w-full max-w-none overflow-visible bg-transparent p-4 text-left outline-none data-[closing]:pointer-events-none max-sm:mb-0 max-sm:p-2 sm:w-[calc(100%-2rem)]',
SIZE[panel.size ?? 'md'],
scrim,
)}
>
{present && (
<DialogPanel {...panel} titleId={`${id}-t`} descriptionId={`${id}-d`} hideClose={hideClose ?? alert} onClose={close} footer={typeof footer === 'function' ? footer(close) : footer} className="max-sm:max-w-none">
{children}
</DialogPanel>
)}
</dialog>
</>
);
}
const ARRIVE: Record<'center' | 'end' | 'start' | 'bottom' | 'top', string> = {
center: '[&>:last-child]:translate-y-4 [&>:last-child]:scale-[0.96]',
end: '[&>:last-child]:translate-x-10',
start: '[&>:last-child]:-translate-x-10',
bottom: '[&>:last-child]:translate-y-10',
top: '[&>:last-child]:-translate-y-4 [&>:last-child]:scale-[0.97]',
};
/**
* A frame that shows an overlay in place on a dimmed page, for demos and reviews.
* When it scrolls into view the page dims and the overlay arrives the way it opens.
*/
export function OverlayPreview({ align = 'center', className, children }: { align?: 'center' | 'end' | 'start' | 'bottom' | 'top'; className?: string; children: React.ReactNode }) {
const [ref, inView] = useInView<HTMLDivElement>('0px 0px -15% 0px');
return (
<div
ref={ref}
aria-hidden
className={cn(
'pointer-events-none relative isolate flex w-full overflow-hidden rounded-2xl border border-border bg-background select-none [&_*]:pointer-events-none',
'[&>:last-child]:transition-[opacity,translate,scale] [&>:last-child]:duration-700 [&>:last-child]:ease-[cubic-bezier(0.34,1.2,0.64,1)] motion-reduce:[&>:last-child]:transition-none',
!inView && cn('[&>:last-child]:opacity-0', ARRIVE[align]),
align === 'center' && 'items-center justify-center p-4 sm:p-10',
align === 'end' && 'justify-end',
align === 'start' && 'justify-start',
align === 'bottom' && 'items-end',
align === 'top' && 'items-start',
className,
)}
inert
>
<div className="absolute inset-0 -z-20 grid content-start gap-3 p-6 opacity-70">
<div className="mb-3 flex items-center gap-2">
<span className="size-5 rounded-md bg-surface-2" />
<span className="h-2.5 w-24 rounded-full bg-surface-2" />
<span className="ms-auto h-7 w-20 rounded-md bg-surface-2" />
</div>
{Array.from({ length: 9 }, (_, i) => (
<span key={i} className="h-2.5 rounded-full bg-surface-2" style={{ width: `${[92, 80, 86, 55, 90, 74, 82, 64, 88][i]}%` }} />
))}
</div>
<div className={cn('absolute inset-0 -z-10 bg-scrim transition-opacity duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'opacity-0')} />
{children}
</div>
);
}Häufige Fragen
Wie installiere ich Dialog?
Mit aiw add @ai-website/dialog. Der Befehl legt die Datei components/ui/dialog.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Dialog?
2 Varianten, Mit Formular und Bestätigung.
Kann ich die Texte von Dialog übersetzen?
Ja. Dialog bringt 1 eigene Texte auf Deutsch und Englisch mit, etwa „Schließen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Dialog einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Dialog mit aiw_components und installieren sie mit aiw_add @ai-website/dialog.