v0.1.6·How-To
Create a Modal Dialog
Build modal dialogs with proper focus management and keyboard interactions.
Published: 12/17/2025

Create a Modal Dialog

This guide shows how to create modal dialogs that trap focus and respond to keyboard events.

Simple modal

Use React state to control modal visibility:

import { useState } from 'react';
import { Button, Card } from 'ui-lab-components';

export function ModalExample() {
  const [isOpen, setIsOpen] = useState(false);

  const handleClose = () => setIsOpen(false);

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

      {isOpen && (
        <div className="fixed inset-0 bg-black/50 flex items-center justify-center">
          <Card className="w-full max-w-md">
            <Card.Header>
              <Card.Title>Confirm Action</Card.Title>
            </Card.Header>
            <Card.Content>
              <p>Are you sure you want to continue?</p>
            </Card.Content>
            <Card.Footer className="flex gap-2">
              <Button variant="secondary" onClick={handleClose}>
                Cancel
              </Button>
              <Button onClick={() => { handleClose(); /* do action */ }}>
                Confirm
              </Button>
            </Card.Footer>
          </Card>
        </div>
      )}
    </>
  );
}

Close on escape

Add keyboard handling:

import { useEffect } from 'react';

export function ModalExample() {
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        setIsOpen(false);
      }
    };

    if (isOpen) {
      window.addEventListener('keydown', handleKeyDown);
      return () => window.removeEventListener('keydown', handleKeyDown);
    }
  }, [isOpen]);

  // rest of component...
}

Close on backdrop click

Dismiss when clicking outside the modal:

const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
  if (e.target === e.currentTarget) {
    setIsOpen(false);
  }
};

{isOpen && (
  <div
    className="fixed inset-0 bg-black/50 flex items-center justify-center"
    onClick={handleBackdropClick}
  >
    {/* Modal content */}
  </div>
)}

Focus management

Keep focus inside the modal while it's open. Use useRef to trap focus:

import { useRef, useEffect } from 'react';

export function ModalExample() {
  const [isOpen, setIsOpen] = useState(false);
  const modalRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (isOpen && modalRef.current) {
      const focusableElements = modalRef.current.querySelectorAll(
        'button, input, [tabindex]:not([tabindex="-1"])'
      );

      if (focusableElements.length > 0) {
        (focusableElements[0] as HTMLElement).focus();
      }
    }
  }, [isOpen]);

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

      {isOpen && (
        <div
          ref={modalRef}
          className="fixed inset-0 bg-black/50 flex items-center justify-center"
          onClick={(e) => {
            if (e.target === e.currentTarget) setIsOpen(false);
          }}
          onKeyDown={(e) => {
            if (e.key === 'Escape') setIsOpen(false);
          }}
        >
          <Card className="w-full max-w-md">
            {/* Modal content */}
          </Card>
        </div>
      )}
    </>
  );
}

Animated modal

Add fade-in animation:

{isOpen && (
  <div
    className="fixed inset-0 bg-black/50 flex items-center justify-center animate-fade-in"
    onClick={handleBackdropClick}
  >
    <Card className="w-full max-w-md animate-scale-in">
      {/* Modal content */}
    </Card>
  </div>
)}

Add these to your Tailwind config or global CSS:

@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes scaleIn {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

Complete example with all features

import { useState, useRef, useEffect } from 'react';
import { Button, Card } from 'ui-lab-components';

export function AdvancedModal() {
  const [isOpen, setIsOpen] = useState(false);
  const modalRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && isOpen) {
        setIsOpen(false);
      }
    };

    if (isOpen) {
      window.addEventListener('keydown', handleKeyDown);
      return () => window.removeEventListener('keydown', handleKeyDown);
    }
  }, [isOpen]);

  const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
    if (e.target === e.currentTarget) {
      setIsOpen(false);
    }
  };

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

      {isOpen && (
        <div
          ref={modalRef}
          className="fixed inset-0 bg-black/50 flex items-center justify-center"
          onClick={handleBackdropClick}
        >
          <Card className="w-full max-w-md">
            <Card.Header>
              <Card.Title>Delete Item</Card.Title>
            </Card.Header>
            <Card.Content>
              <p>This action cannot be undone.</p>
            </Card.Content>
            <Card.Footer className="flex gap-2">
              <Button variant="secondary" onClick={() => setIsOpen(false)}>
                Keep
              </Button>
              <Button variant="destructive" onClick={() => setIsOpen(false)}>
                Delete
              </Button>
            </Card.Footer>
          </Card>
        </div>
      )}
    </>
  );
}

Next step

Learn to validate form inputs inside modals in Build a Form with Validation.

© 2025 UI Lab • Built for humans and machines