Dialog
Generic modal with header/body/footer, focus trap, Esc + backdrop close, and a portal.
import { Dialog }from "@/components/ui"
Specification
A generic portalled modal with optional header/body/footer, focus trapping, and Esc + backdrop dismissal.
Props (8)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| open* | boolean | — | Controls whether the dialog is rendered. |
| onClose* | () => void | — | Called when the dialog requests to close (Esc, backdrop, or close button). |
| title | ReactNode | — | Optional heading; also wires up aria-labelledby. |
| description | ReactNode | — | Optional supporting text; also wires up aria-describedby. |
| footer | ReactNode | — | Footer content, typically action buttons. |
| size | "sm" | "md" | "lg" | "md" | Maximum width of the dialog panel. |
| closeOnBackdrop | boolean | true | Whether clicking the backdrop closes the dialog. |
| children | ReactNode | — | Main body content of the dialog. |
With form + actions
Full source
The complete component, read from
src/components/ui/dialog.tsx.dialog.tsx
"use client";
import { useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMounted, useFocusTrap } from "@/hooks";
type DialogSize = "sm" | "md" | "lg";
const SIZE: Record<DialogSize, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
};
interface DialogProps {
open: boolean;
onClose: () => void;
title?: ReactNode;
description?: ReactNode;
footer?: ReactNode;
size?: DialogSize;
closeOnBackdrop?: boolean;
children?: ReactNode;
}
export function Dialog({
open,
onClose,
title,
description,
footer,
size = "md",
closeOnBackdrop = true,
children,
}: DialogProps) {
const panelRef = useRef<HTMLDivElement>(null);
const mounted = useMounted();
useFocusTrap(panelRef, open, onClose);
if (!open || !mounted) return null;
const labelledBy = title ? "dialog-title" : undefined;
const describedBy = description ? "dialog-desc" : undefined;
return createPortal(
<div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-overlay p-4"
onClick={closeOnBackdrop ? onClose : undefined}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
aria-describedby={describedBy}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
className={cn(
"flex w-full flex-col overflow-hidden rounded-xl bg-surface shadow-overlay outline-none",
SIZE[size]
)}
>
{(title || description) && (
<div className="flex items-start justify-between gap-4 px-5 pb-3 pt-5">
<div className="min-w-0">
{title && (
<h2 id="dialog-title" className="text-base font-semibold text-text">
{title}
</h2>
)}
{description && (
<p id="dialog-desc" className="mt-1 text-sm leading-relaxed text-text-secondary">
{description}
</p>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
className="-mr-1 -mt-1 shrink-0 rounded-md p-1 text-text-tertiary hover:bg-bg-hover hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<X size={18} aria-hidden />
</button>
</div>
)}
{children && <div className="px-5 py-2 text-sm text-text">{children}</div>}
{footer && (
<div className="mt-2 flex items-center justify-end gap-2 border-t border-border bg-bg px-5 py-3">
{footer}
</div>
)}
</div>
</div>,
document.body
);
}Dependencies
To use Dialog you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ ├─ button.tsx ├─ dialog.tsx ├─ field.tsx └─ input.tsx src/hooks/ ├─ use-focus-trap.ts ├─ use-mounted.ts └─ use-popover-position.ts src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/components/ui/field.tsx
field.tsx
"use client";
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface FieldCounter {
text: string;
warn?: boolean;
}
interface FieldProps {
label?: string;
hint?: string;
error?: string;
required?: boolean;
htmlFor?: string;
counter?: FieldCounter;
messageId?: string;
children: ReactNode;
}
export function Field({
label,
hint,
error,
required,
htmlFor,
counter,
messageId,
children,
}: FieldProps) {
const message = error || hint;
return (
<div className="flex w-full flex-col gap-1.5">
{label && (
<label
htmlFor={htmlFor}
className="flex items-center gap-1 text-[13px] font-medium text-text"
>
{label}
{required && (
<span className="text-error" aria-hidden>
*
</span>
)}
</label>
)}
{children}
<div className="flex items-start justify-between gap-3">
<p
id={messageId}
className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
>
{message || "\u00A0"}
</p>
{counter && (
<span
className={cn(
"shrink-0 text-xs tabular-nums",
counter.warn ? "text-error" : "text-text-tertiary"
)}
>
{counter.text}
</span>
)}
</div>
</div>
);
}
export const controlBase = cn(
"w-full rounded-lg border text-sm bg-surface text-text transition-colors",
"placeholder:text-text-tertiary",
"focus:outline-none focus-visible:outline-none focus-visible:border-accent",
"disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
"read-only:bg-bg-secondary"
);
export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
return cn(
error ? "border-error focus-visible:border-error" : "border-border",
readOnly && "read-only:bg-bg-secondary",
disabled && "text-text-tertiary"
);
}src/hooks/use-focus-trap.ts
use-focus-trap.ts
"use client";
import { useEffect, type RefObject } from "react";
const FOCUSABLE =
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
/**
* useFocusTrap — while `active`, traps Tab focus within `ref`, focuses the
* first focusable element on open, restores focus to the previously focused
* element on close, and calls `onEscape` when Escape is pressed. Also locks
* body scroll while active.
*/
export function useFocusTrap(
ref: RefObject<HTMLElement | null>,
active: boolean,
onEscape?: () => void
) {
useEffect(() => {
if (!active) return;
const node = ref.current;
if (!node) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
// Focus the first focusable element (or the container).
const focusables = () =>
Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null
);
const first = focusables()[0];
(first ?? node).focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscape?.();
return;
}
if (e.key !== "Tab") return;
const items = focusables();
if (items.length === 0) {
e.preventDefault();
return;
}
const firstEl = items[0];
const lastEl = items[items.length - 1];
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
};
document.addEventListener("keydown", onKeyDown);
// Lock body scroll.
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prevOverflow;
previouslyFocused?.focus?.();
};
}, [active, ref, onEscape]);
}src/hooks/use-mounted.ts
use-mounted.ts
"use client";
import { useState, useEffect } from "react";
/**
* Returns true once the component has mounted on the client.
* Useful for portals and anything that needs `document` to exist.
*/
export function useMounted(): boolean {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";
import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";
interface PopoverPositionOptions {
align?: "start" | "center" | "end";
gap?: number;
viewportPadding?: number;
preferredHeight?: number;
matchWidth?: boolean;
minWidth?: number;
}
interface PopoverPosition {
placement: "top" | "bottom";
style: CSSProperties;
}
/**
* Compute a fixed-position popover style anchored to a ref element.
* Opens downward by default; flips upward when there isn't room below
* AND there's more room above. Clamps horizontally so nothing renders
* past the viewport edge. Re-measures on scroll/resize while open.
*/
export function usePopoverPosition(
anchorRef: RefObject<HTMLElement | null>,
open: boolean,
options: PopoverPositionOptions = {}
): PopoverPosition | null {
const {
align = "start",
gap = 4,
viewportPadding = 8,
preferredHeight = 320,
matchWidth = false,
minWidth,
} = options;
const [position, setPosition] = useState<PopoverPosition | null>(null);
const measure = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceBelow = vh - rect.bottom - viewportPadding;
const spaceAbove = rect.top - viewportPadding;
const placement =
spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
? "top"
: "bottom";
const maxHeight = Math.max(
120,
Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
);
const width = matchWidth ? rect.width : minWidth;
let left = align === "end" && width ? rect.right - width : rect.left;
if (align === "center" && width)
left = rect.left + rect.width / 2 - width / 2;
left = width
? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
: Math.min(Math.max(left, viewportPadding), vw - viewportPadding);
setPosition({
placement,
style: {
position: "fixed",
top: placement === "top" ? undefined : rect.bottom + gap,
bottom: placement === "top" ? vh - rect.top + gap : undefined,
left,
width: matchWidth ? rect.width : undefined,
minWidth: !matchWidth ? minWidth : undefined,
maxHeight,
},
});
}, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);
useEffect(() => {
if (!open) {
setPosition(null);
return;
}
measure();
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
};
}, [open, measure]);
return position;
}src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}