Skip to content
UI Kit
Accent color

Modal dialog for React

A modal on the native dialog element. Focus stays inside, Escape and the backdrop close it, and focus returns to the trigger.

One of 9 components in Overlays

2 variants

A modal that handles focus for you

A dialog interrupts the page for one decision or one short task. This one is built on the native dialog element, so the browser keeps focus inside, Escape closes it, the page behind it stays inert and focus returns to the button that opened it. That is the behaviour screen reader users rely on.

With a form fits short tasks such as renaming a project or inviting a person, with the primary action at the bottom and the form submitted by Enter. Confirmation asks before something that cannot be undone, names the object in the title and makes the destructive button say exactly what happens.

Keep a dialog to one task and at most five fields. Write the title as the question or the action, not Notice. For filters or long details use a sheet instead, and for a success message a toast, a dialog should be the exception on a page.

Variants

With a form

Title, text, fields and actions. On phones the dialog sits at the bottom, near the thumb.

Confirmation

An alert dialog for destructive steps. It names the thing and the consequence and waits for an answer.

Details

What Dialog brings along, read from the registry and the source.

Category
Overlays
Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/dialog.tsx
Brings along
utilskiticon
npm packages
None
Languages
English and German, 1 text of its own
Studio
Adjust in code

Props

The DialogProps interface from components/ui/dialog.tsx, with the comments from the code. Also takes every prop of Omit<DialogPanelProps, 'onClose' | 'titleId' | 'descriptionId' | 'footer'>.

  • triggeroptional
    React.ReactElement

    The button that opens it

  • openoptional
    boolean
  • defaultOpenoptional
    boolean
  • onOpenChangeoptional
    (open: boolean) => void
  • footeroptional
    React.ReactNode | ((close: () => void) => React.ReactNode)

    Footer actions. A function gets close().

  • roleoptional
    'dialog' | 'alertdialog'

    alertdialog: needs an answer, no backdrop click, no ×

Built-in texts

Dialog writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 1 texts
closeEnglish CloseGerman Schließen

Accessibility

Read from the source of the component.

  • Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
  • Sets aria-describedby, aria-label and aria-labelledby, so screen readers announce purpose and state.
  • Uses the role alert.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 391 lines
components/ui/dialog.tsx
'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>
  );
}

Frequent questions

How do I install Dialog?

With aiw add @ai-website/dialog. The command puts components/ui/dialog.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Dialog have?

2 variants, With a form and Confirmation.

Can I translate the texts of Dialog?

Yes. Dialog carries 1 texts of its own in English and German, such as “Close”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Dialog?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Dialog with aiw_components and install it with aiw_add @ai-website/dialog.

Often searched

Jump to

Copied to the clipboard