React Modal Patterns: Accessible Dialogs, Focus Trap, Nested Modals, and Bottom Sheets
Build accessible modal dialogs in React without a library. Covers focus trap with aria-modal, keyboard navigation (Escape/Tab/Shift-Tab), scroll lock, nested dialog stacking, bottom sheet for mobile, and Radix UI Dialog integration.
Modals are one of the most accessibility-tricky UI patterns. A visually correct modal can fail completely for keyboard and screen reader users: focus must move into the modal on open, Tab must cycle only within the modal, Escape must close it, and focus must return to the trigger on close. Getting all of this right from scratch takes real effort โ which is why Radix UI Dialog is usually the right choice. But understanding the underlying mechanics makes you a better consumer of any library.
Option A: Radix UI Dialog (Recommended)
npm install @radix-ui/react-dialog
// components/ui/dialog.tsx โ Radix Dialog with Tailwind styling
import * as RadixDialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
// Overlay
function DialogOverlay({ className, ...props }: RadixDialog.DialogOverlayProps) {
return (
<RadixDialog.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
);
}
// Content panel
function DialogContent({
className,
children,
...props
}: RadixDialog.DialogContentProps) {
return (
<RadixDialog.Portal>
<DialogOverlay />
<RadixDialog.Content
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2",
"bg-white rounded-2xl shadow-xl border border-gray-200 p-6",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2 data-[state=open]:slide-in-from-left-1/2",
"data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%]",
className
)}
{...props}
>
{children}
<RadixDialog.Close className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors">
<X className="w-5 h-5" />
<span className="sr-only">Close</span>
</RadixDialog.Close>
</RadixDialog.Content>
</RadixDialog.Portal>
);
}
// Exports
export const Dialog = RadixDialog.Root;
export const DialogTrigger = RadixDialog.Trigger;
export const DialogClose = RadixDialog.Close;
export const DialogTitle = RadixDialog.Title;
export const DialogDescription = RadixDialog.Description;
export { DialogContent };
// Usage:
// <Dialog>
// <DialogTrigger asChild><Button>Open</Button></DialogTrigger>
// <DialogContent>
// <DialogTitle>Delete project</DialogTitle>
// <DialogDescription>This action cannot be undone.</DialogDescription>
// ...
// </DialogContent>
// </Dialog>
Confirmation Dialog Pattern
// components/ui/confirm-dialog.tsx
import { useState, useCallback } from "react";
import {
Dialog, DialogContent, DialogTitle, DialogDescription, DialogClose,
} from "./dialog";
interface ConfirmDialogProps {
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: "default" | "destructive";
onConfirm: () => void | Promise<void>;
trigger: React.ReactNode;
}
export function ConfirmDialog({
title, description, confirmText = "Confirm", cancelText = "Cancel",
variant = "default", onConfirm, trigger,
}: ConfirmDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleConfirm = useCallback(async () => {
setLoading(true);
try {
await onConfirm();
setOpen(false);
} finally {
setLoading(false);
}
}, [onConfirm]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<div onClick={() => setOpen(true)}>{trigger}</div>
<DialogContent className="max-w-md">
<DialogTitle className="text-lg font-semibold text-gray-900 pr-8">
{title}
</DialogTitle>
<DialogDescription className="text-sm text-gray-500 mt-2">
{description}
</DialogDescription>
<div className="flex gap-3 mt-6 justify-end">
<DialogClose asChild>
<button className="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50">
{cancelText}
</button>
</DialogClose>
<button
onClick={handleConfirm}
disabled={loading}
className={`px-4 py-2 text-sm font-semibold rounded-lg disabled:opacity-50 ${
variant === "destructive"
? "bg-red-600 text-white hover:bg-red-700"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
>
{loading ? "โฆ" : confirmText}
</button>
</div>
</DialogContent>
</Dialog>
);
}
// Usage:
// <ConfirmDialog
// title="Delete project"
// description="All data will be permanently deleted. This cannot be undone."
// confirmText="Delete project"
// variant="destructive"
// onConfirm={deleteProject}
// trigger={<Button variant="destructive">Delete</Button>}
// />
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Option B: Build From Scratch (Focus Trap)
// hooks/use-focus-trap.ts
import { useEffect, useRef } from "react";
const FOCUSABLE_SELECTORS = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
export function useFocusTrap(active: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!active) return;
// Save the element that had focus before modal opened
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus first focusable element in modal
const container = containerRef.current;
if (!container) return;
const first = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)[0];
first?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const focusable = Array.from(
container!.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
// Shift+Tab: if on first element, wrap to last
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
// Tab: if on last element, wrap to first
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}, [active]);
return containerRef;
}
// Scroll lock hook
export function useScrollLock(active: boolean) {
useEffect(() => {
if (!active) return;
const scrollY = window.scrollY;
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";
return () => {
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
window.scrollTo(0, scrollY); // Restore scroll position
};
}, [active]);
}
// components/modal/modal.tsx โ from scratch with focus trap
"use client";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { useFocusTrap, useScrollLock } from "@/hooks/use-focus-trap";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
size?: "sm" | "md" | "lg";
}
const SIZES = {
sm: "max-w-md",
md: "max-w-lg",
lg: "max-w-2xl",
};
export function Modal({ open, onClose, title, children, size = "md" }: ModalProps) {
const containerRef = useFocusTrap(open);
useScrollLock(open);
// Escape key to close
useEffect(() => {
if (!open) return;
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
onClick={onClose}
/>
{/* Dialog */}
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className={`
fixed left-1/2 top-1/2 z-50 w-full ${SIZES[size]}
-translate-x-1/2 -translate-y-1/2
bg-white rounded-2xl shadow-xl border border-gray-200 p-6
`}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">
{title}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close dialog"
>
<X className="w-5 h-5" />
</button>
</div>
{children}
</div>
</>,
document.body
);
}
Bottom Sheet for Mobile
// components/modal/bottom-sheet.tsx
"use client";
import { createPortal } from "react-dom";
import { useFocusTrap, useScrollLock } from "@/hooks/use-focus-trap";
import { useEffect } from "react";
interface BottomSheetProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function BottomSheet({ open, onClose, title, children }: BottomSheetProps) {
const containerRef = useFocusTrap(open);
useScrollLock(open);
useEffect(() => {
if (!open) return;
const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<>
<div className="fixed inset-0 z-50 bg-black/40" aria-hidden="true" onClick={onClose} />
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="sheet-title"
className={`
fixed bottom-0 left-0 right-0 z-50
bg-white rounded-t-2xl shadow-xl
max-h-[85vh] flex flex-col
transition-transform duration-300
${open ? "translate-y-0" : "translate-y-full"}
`}
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 bg-gray-200 rounded-full" />
</div>
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-100">
<h2 id="sheet-title" className="text-base font-semibold text-gray-900">
{title}
</h2>
<button onClick={onClose} aria-label="Close" className="text-gray-400 hover:text-gray-600">
โ
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
</div>
</>,
document.body
);
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Cost and Timeline Estimates
| Approach | Team | Timeline | Cost Range |
|---|---|---|---|
| Radix UI Dialog integration | 1 dev | Half a day | $150โ300 |
| Custom modal with focus trap from scratch | 1 dev | 1โ2 days | $400โ800 |
| Confirm dialog + bottom sheet + nested support | 1 dev | 2โ3 days | $600โ1,200 |
See Also
- React Design System with Radix UI
- React Accessibility and ARIA Patterns
- React Compound Components
- React Keyboard Shortcuts
- React Error Boundaries
Working With Viprasol
Accessible modals require more than display:none toggling โ focus management, scroll lock, Escape handling, and screen reader aria-modal are all mandatory. Our team builds modal systems with Radix UI Dialog (which handles all the ARIA mechanics correctly), styled with your design system, plus custom focus-trap hooks when building from scratch is required.
What we deliver:
- Radix
Dialogwith portal, overlay, close button, and Tailwind animation classes ConfirmDialogwrapper: async onConfirm, loading state, destructive variantuseFocusTrap: Tab/Shift-Tab cycle within container, focus-first on open, restore on closeuseScrollLock:position:fixed+top:-${scrollY}pxpattern, restores scroll position on unmount- Custom
Modal:createPortal,role=dialog aria-modal aria-labelledby, Escape handler BottomSheet: fixed bottom, translate-y animation, drag handle, max-h-85vh scrollable body
Talk to our team about your component library needs โ
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.