Modal

A dialog component for displaying content that requires user attention or interaction. Accessible and keyboard-friendly.

Installation

Terminal
ReactTailwind
TSX
npx @uiblox/cli add modal

Visual Variations

Toggle between three different visual styles: default (standard), centered (compact), and full width (wide).

Standard dialog

Alert dialog

Form modal

Basic Usage

Copy & paste ready
const [open, setOpen] = useState(false);

<Button onClick={() => setOpen(true)}>Open Modal</Button>

<Modal open={open} onClose={() => setOpen(false)}>
  <ModalClose onClose={() => setOpen(false)} />
  <ModalHeader>
    <ModalTitle>Edit Profile</ModalTitle>
    <ModalDescription>Make changes to your profile here.</ModalDescription>
  </ModalHeader>
  <ModalContent>
    <p>Your profile information goes here.</p>
  </ModalContent>
  <ModalFooter>
    <Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
    <Button onClick={() => setOpen(false)}>Save Changes</Button>
  </ModalFooter>
</Modal>

Confirmation Dialog

Copy & paste ready
const [open, setOpen] = useState(false);

<Button variant="destructive" onClick={() => setOpen(true)}>Delete Item</Button>

<Modal open={open} onClose={() => setOpen(false)}>
  <ModalHeader>
    <ModalTitle>Are you sure?</ModalTitle>
    <ModalDescription>
      This action cannot be undone. This will permanently delete your item.
    </ModalDescription>
  </ModalHeader>
  <ModalFooter className="pt-6">
    <Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
    <Button variant="destructive" onClick={() => setOpen(false)}>Delete</Button>
  </ModalFooter>
</Modal>

Accessibility

Modal dialogs require careful accessibility handling. UIBlox Modal includes focus trapping, escape key handling, and proper ARIA attributes by default.

Keyboard Support

  • Escape - Close the modal
  • Tab - Navigate within modal (trapped)
  • Shift+Tab - Navigate backwards

Built-in Features

  • role="dialog" and aria-modal
  • Body scroll prevention
  • Focus returns to trigger on close
  • Backdrop click to dismiss

Accessible Modal Patterns

// Label the modal with aria-labelledby
<Modal open={open} onClose={onClose}>
  <ModalHeader>
    <ModalTitle id="modal-title">Edit Profile</ModalTitle>
    <ModalDescription id="modal-desc">
      Make changes to your profile information.
    </ModalDescription>
  </ModalHeader>
  {/* ... */}
</Modal>

// For alert dialogs (requires immediate attention)
<Modal
  open={open}
  onClose={onClose}
  role="alertdialog"
  aria-labelledby="alert-title"
  aria-describedby="alert-desc"
>
  <ModalHeader>
    <ModalTitle id="alert-title">Delete Account?</ModalTitle>
    <ModalDescription id="alert-desc">
      This action is permanent and cannot be undone.
    </ModalDescription>
  </ModalHeader>
  <ModalFooter>
    <Button variant="outline" onClick={onClose}>Cancel</Button>
    <Button variant="destructive" onClick={handleDelete}>
      Delete Forever
    </Button>
  </ModalFooter>
</Modal>

// Auto-focus the first interactive element
<Modal open={open} onClose={onClose}>
  <ModalContent>
    <Input
      autoFocus  // Focus moves here when modal opens
      placeholder="Enter your name"
    />
  </ModalContent>
</Modal>

// Close button with proper label
<ModalClose onClose={onClose} aria-label="Close dialog" />

Best Practices

  • • Always provide a clear title via ModalTitle
  • • Use role="alertdialog" for important confirmations
  • • Focus should move to the modal when opened
  • • Ensure the close button is keyboard accessible
  • • Don't trap users—always provide a way to dismiss

Features

  • Press Esc to close
  • Click backdrop to close
  • Prevents body scroll when open
  • Accessible with proper ARIA attributes
  • Smooth animations

Props

PropTypeDefaultDescription
open*boolean-Whether the modal is open
onClose*() => void-Callback when the modal should close
children*React.ReactNode-The modal content
classNamestring-Additional CSS classes for the modal container

Source Code

Copy this code into src/components/ui/modal.tsx:

modal.tsx
ReactTailwind
TSX
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

interface ModalProps {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
  className?: string;
}

const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
  ({ open, onClose, children, className }, ref) => {
    React.useEffect(() => {
      const handleEscape = (e: KeyboardEvent) => {
        if (e.key === "Escape") onClose();
      };
      if (open) {
        document.addEventListener("keydown", handleEscape);
        document.body.style.overflow = "hidden";
      }
      return () => {
        document.removeEventListener("keydown", handleEscape);
        document.body.style.overflow = "";
      };
    }, [open, onClose]);

    if (!open) return null;

    return (
      <div className="fixed inset-0 z-50">
        <div
          className="fixed inset-0 bg-black/50 backdrop-blur-sm"
          onClick={onClose}
          aria-hidden="true"
        />
        <div className="fixed inset-0 flex items-center justify-center p-4">
          <div
            ref={ref}
            role="dialog"
            aria-modal="true"
            className={cn(
              "relative w-full max-w-lg bg-white dark:bg-gray-900 rounded-lg shadow-xl",
              "animate-in fade-in-0 zoom-in-95 duration-200",
              className
            )}
            onClick={(e) => e.stopPropagation()}
          >
            {children}
          </div>
        </div>
      </div>
    );
  }
);
Modal.displayName = "Modal";

const ModalHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6 pb-0", className)} {...props} />
  )
);
ModalHeader.displayName = "ModalHeader";

const ModalTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
  ({ className, ...props }, ref) => (
    <h2
      ref={ref}
      className={cn("text-lg font-semibold leading-none tracking-tight text-gray-900 dark:text-white", className)}
      {...props}
    />
  )
);
ModalTitle.displayName = "ModalTitle";

const ModalDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
  ({ className, ...props }, ref) => (
    <p ref={ref} className={cn("text-sm text-gray-600 dark:text-gray-400", className)} {...props} />
  )
);
ModalDescription.displayName = "ModalDescription";

const ModalContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("p-6", className)} {...props} />
  )
);
ModalContent.displayName = "ModalContent";

const ModalFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 p-6 pt-0", className)}
      {...props}
    />
  )
);
ModalFooter.displayName = "ModalFooter";

const ModalClose = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement> & { onClose?: () => void }
>(({ className, onClose, ...props }, ref) => (
  <button
    ref={ref}
    onClick={onClose}
    className={cn(
      "absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
      className
    )}
    {...props}
  >
    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
    </svg>
    <span className="sr-only">Close</span>
  </button>
));
ModalClose.displayName = "ModalClose";

export { Modal, ModalHeader, ModalTitle, ModalDescription, ModalContent, ModalFooter, ModalClose };