Back to Blog

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.

Viprasol Tech Team
May 24, 2027
12 min read

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

ApproachTeamTimelineCost Range
Radix UI Dialog integration1 devHalf a day$150โ€“300
Custom modal with focus trap from scratch1 dev1โ€“2 days$400โ€“800
Confirm dialog + bottom sheet + nested support1 dev2โ€“3 days$600โ€“1,200

See Also


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 Dialog with portal, overlay, close button, and Tailwind animation classes
  • ConfirmDialog wrapper: async onConfirm, loading state, destructive variant
  • useFocusTrap: Tab/Shift-Tab cycle within container, focus-first on open, restore on close
  • useScrollLock: position:fixed + top:-${scrollY}px pattern, 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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.