v1.0

Modal

Preview

Basic

Preview

Code

ts
import Modal from '@/components/overlay/Modal';

src/components/overlay/Modal.tsx

AI prompt

text
Build a centred modal dialog component in React + TypeScript + Tailwind CSS.

## Look
- Portalled to <body> (a `fixed` element inside an ancestor with a transform or backdrop-filter is positioned against that ancestor, so the dialog would centre inside a card instead of the page).
- Overlay: `fixed inset-0 z-[200] flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm`, fading in (opacity 0 → 1, 200ms ease-out).
- Dialog: `relative flex w-full flex-col text-left` on the opaque floating surface, capped by `size`: `sm` … `7xl` map to `max-w-sm` … `max-w-7xl` (default `md`). It opens with a scale-in: from `opacity: 0; transform: scale(0.96) translateY(6px)` to rest, 220ms `cubic-bezier(0.34, 1.56, 0.64, 1)` (a slight overshoot).
- Header: wrapper `shrink-0 px-5 pt-5` (the whole wrapper, padding included, is the drag handle), inner row `flex items-start justify-between gap-3 border-b border-slate-200 dark:border-slate-700 pb-3`. Title: text-sm font-semibold leading-5 slate-800 / dark slate-100. Close: a 16px X, `rounded p-0.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200`.
- Body: `min-h-0 flex-1 px-5 pb-5 pt-4`. It becomes `overflow-y-auto` only once the user has dragged in a height. Until then it grows with its content, so a dropdown inside is never clipped by the dialog.
- Resize mark: a decorative 10px SVG in the bottom-right corner (`absolute bottom-1 right-1 h-2.5 w-2.5 text-slate-300 dark:text-slate-600 pointer-events-none`) with two short diagonal strokes (`M9 3 3 9M9 6.5 6.5 9`, stroke 1.2, round caps).

## Behaviour
- Controlled: renders nothing while `isOpen` is false.
- Escape and the close button call `onClose`. A click outside the dialog closes it ONLY with `closeOnBackdrop` (off by default: dialogs hold forms, and one stray click should not throw away what was typed).
- A portalled popover opened from inside the dialog (mark such surfaces with `data-overlay="popover"` / `"picker"`) is not "outside": a click in one does not close the dialog, and while one is open the dialog ignores Escape. One key closes one thing.
- Drag: pointer-down on the header (skip presses that land on buttons, links or fields) moves the dialog. Header gets `cursor-move select-none touch-none`. Apply the move as an offset through the CSS `translate` property, not `transform`, so it composes with the open animation and flex centring still does the layout. Clamp so no edge leaves the viewport. Double-clicking the header puts it back.
- Resize from any edge or corner: invisible grips inside the border. Edges are 6px strips inset 12px from the corners, corners are 12px squares on top of them, with ns/ew/nwse/nesw cursors and `touch-none`. Min 280×160, clamped to the viewport. The box is centred, so it grows both ways: dragging the right edge 40px widens it 40px and moves the offset 20px, which keeps the left edge still. Only the dragged axis gets an explicit size. A resized size is capped at `calc(100vw - 1rem)` × `calc(100vh - 1rem)`.
- Position and size reset on close, so it always reopens centred.

## API
- `isOpen: boolean`, `onClose(): void`, `title: string`, `children`
- `size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'` (default `'md'`)
- `closeOnBackdrop?` = false, `draggable?` = true, `resizable?` = true

## Accessibility
- `role="dialog" aria-modal="true" aria-label={title}`; close button `aria-label="Close"`; grips and the mark `aria-hidden`.

## Demo
An "Open modal" button opening "Invite a member" (md) with an Email field (placeholder "name@example.com") and a right-aligned ghost Cancel plus a primary "Send invite".

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: marketing-stats (96S1). Restyled onto the kit's surface and portalled. */

import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
import { ResizeMark, useDragResize } from '@/lib/use-drag-resize';

const SIZE = {
  sm: 'max-w-sm',
  md: 'max-w-md',
  lg: 'max-w-lg',
  xl: 'max-w-xl',
  '2xl': 'max-w-2xl',
  '3xl': 'max-w-3xl',
  '4xl': 'max-w-4xl',
  '5xl': 'max-w-5xl',
  '6xl': 'max-w-6xl',
  '7xl': 'max-w-7xl',
} as const;

export type ModalSize = keyof typeof SIZE;

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  size?: ModalSize;
  /**
   * Close on a click outside the dialog. Off by default: a dialog usually
   * holds a form, and one stray click beside it should not throw away what was
   * typed. Escape and the close button always close it.
   */
  closeOnBackdrop?: boolean;
  /** Drag it by the title bar; double-click the bar to put it back. Default on. */
  draggable?: boolean;
  /** Resize it from any edge or corner. Default on. */
  resizable?: boolean;
}

const NOOP = () => {};

/**
 * A centred dialog over a dimmed backdrop. Escape and the close button dismiss
 * it; a click on the backdrop does only with `closeOnBackdrop`. Drag the title
 * bar to move it and any edge or corner to resize it — once given a height,
 * the body scrolls. Both reset when it closes, so it
 * opens centred every time.
 *
 * PORTALLED to <body>, like Drawer, and for the same reason: `fixed` is
 * resolved against the nearest ancestor with a transform, filter or
 * backdrop-filter — and `.panel` has one. Rendered in place inside a card, the
 * backdrop covered the card rather than the page and the dialog centred itself
 * in the wrong box. Escaping the tree is the fix; hunting the ancestor is not.
 */
export function Modal({
  isOpen,
  onClose,
  title,
  children,
  size = 'md',
  closeOnBackdrop = false,
  draggable = true,
  resizable = true,
}: ModalProps) {
  const drag = useDragResize({ draggable, resizable });
  const { reset } = drag;

  // Anything outside the dialog is the backdrop, so click-outside IS the
  // backdrop click; one hook covers it and Escape both. `ignoreOverlays`: a
  // portalled surface opened from inside the dialog (a ConfirmPopover, a
  // column menu) is not the backdrop, so a click in it must not close us.
  // Escape, like Drawer's, is left to a popover or picker open inside the
  // dialog — one key closes one thing.
  useDismiss(
    drag.ref,
    isOpen,
    closeOnBackdrop ? onClose : NOOP,
    () => {
      if (document.querySelector('[data-overlay="popover"], [data-overlay="picker"]')) return;
      onClose();
    },
    true,
  );

  useEffect(() => {
    if (!isOpen) reset();
  }, [isOpen, reset]);

  if (!isOpen) return null;

  const content = (
    <div
      className="fixed inset-0 z-[200] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4 animate-fade-in"
      role="dialog"
      aria-modal="true"
      aria-label={title}
    >
      <div
        ref={drag.ref}
        style={drag.style}
        className={cn(
          'relative flex w-full flex-col panel panel-solid text-left animate-scale-in',
          SIZE[size],
        )}
      >
        {drag.grips}
        {/* The whole bar is the drag handle, padding included, so it is easy to catch. */}
        <div {...drag.handleProps} className={cn('shrink-0 px-5 pt-5', drag.handleProps.className)}>
          <div className="flex items-start justify-between gap-3 border-b border-slate-200 pb-3 dark:border-slate-700">
            <h3 className="text-sm font-semibold leading-5 text-slate-800 dark:text-slate-100">{title}</h3>
            <button
              type="button"
              onClick={onClose}
              aria-label="Close"
              className="shrink-0 rounded p-0.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
            >
              <X className="h-4 w-4" />
            </button>
          </div>
        </div>
        {/* A scroller only once a height was dragged in: until then the body grows,
            and an in-flow dropdown inside it is never clipped by its own dialog. */}
        <div className={cn('min-h-0 flex-1 px-5 pb-5 pt-4', drag.size.h !== undefined && 'overflow-y-auto')}>{children}</div>
        {resizable && <ResizeMark />}
      </div>
    </div>
  );

  return typeof document === 'undefined' ? content : createPortal(content, document.body);
}

Props

PropTypeDefaultDescription
isOpen*boolean—
onClose*() => void—
title*string—
children*React.ReactNode—
sizeModalSize'md'
closeOnBackdropbooleanfalseClose on a click outside the dialog. Off by default: a dialog usually holds a form, and one stray click beside it should not throw away what was typed. Escape and the close button always close it.
draggablebooleantrueDrag it by the title bar; double-click the bar to put it back. Default on.
resizablebooleantrueResize it from any edge or corner. Default on.