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
| Prop | Type | Default | Description |
|---|---|---|---|
isOpen* | boolean | — | |
onClose* | () => void | — | |
title* | string | — | |
children* | React.ReactNode | — | |
size | ModalSize | 'md' | |
closeOnBackdrop | boolean | false | 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. |
draggable | boolean | true | Drag it by the title bar; double-click the bar to put it back. Default on. |
resizable | boolean | true | Resize it from any edge or corner. Default on. |