50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
reactMay 23, 2026·8 min read

Building Modals That Don't Suck — Focus Traps, Escape Keys, and Scroll Lock

Most modal implementations are accessibility disasters. Here's how to build ones that actually work for everyone, keyboard users included.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Building Modals That Don't Suck — Focus Traps, Escape Keys, and Scroll Lock

We've all done it. Slapped a `position: fixed` div on top of everything, added an overlay click handler, and called it a modal. It looks fine in Chrome on a MacBook. It works exactly well enough to ship. Then a QA tester tries to use the keyboard, or someone on iOS notices the background is still scrolling, or a screen reader user gets completely lost inside the page. Congratulations, you've built a fake modal.

Modals are one of those UI patterns that seem dead simple until you start listing everything a real implementation needs: focus management, keyboard handling, scroll locking, aria attributes, portal rendering, animation. Each of these has at least one nasty edge case. We learned most of this the hard way, not from reading specs, but from bug reports.

Why Focus Trapping Is Non-Negotiable

When a modal opens, keyboard focus needs to move inside it and stay there until the modal closes. If it doesn't, Tab will happily cycle through all the links and buttons behind the overlay — elements the user can't even see. For sighted users this is annoying. For screen reader users this is genuinely broken.

The basic algorithm: find all focusable elements inside the modal, intercept Tab and Shift+Tab, and loop focus between the first and last element. When the modal closes, return focus to whatever triggered it. That last part is the one everyone forgets. Nothing worse than opening a modal, closing it, and having focus teleport to the top of the page.

const FOCUSABLE_SELECTORS = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(', ');

function useFocusTrap(containerRef: React.RefObject<HTMLElement>, isOpen: boolean) {
  const previousFocusRef = React.useRef<HTMLElement | null>(null);

  React.useEffect(() => {
    if (!isOpen) return;

    // Store where focus was before the modal opened
    previousFocusRef.current = document.activeElement as HTMLElement;

    const container = containerRef.current;
    if (!container) return;

    // Move focus into the modal
    const firstFocusable = container.querySelector<HTMLElement>(FOCUSABLE_SELECTORS);
    firstFocusable?.focus();

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return;

      const focusable = Array.from(
        container!.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
      );
      if (focusable.length === 0) return;

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (e.shiftKey) {
        if (document.activeElement === first) {
          e.preventDefault();
          last.focus();
        }
      } else {
        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();
    };
  }, [isOpen, containerRef]);
}

One thing worth noting: the `FOCUSABLE_SELECTORS` list above covers most cases but won't catch elements with `contenteditable` or custom widgets. If your modals are simple (buttons, inputs, links) you're fine. If you're building a rich text editor inside a modal, you've got bigger problems anyway.

The Escape Key — Obvious, But Easy to Mess Up

Escape should close the modal. Every user expects this. The implementation is one line of code. And yet. The common mistake is attaching the escape handler to the modal element itself, then forgetting that the modal might not be focused when the user presses Escape. Attach it to `document`, not the modal.

function useEscapeKey(onClose: () => void, isOpen: boolean) {
  React.useEffect(() => {
    if (!isOpen) return;

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') {
        e.stopPropagation(); // Prevent closing nested modals all at once
        onClose();
      }
    }

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);
}

That `stopPropagation` call matters if you're ever stacking modals — a confirmation dialog on top of a settings modal, for example. Without it, one Escape keypress cascades and closes everything. The inner modal catches it first and propagates it up, which closes the outer one too. Not what users expect.

Also wrap `onClose` in `useCallback` at the call site if it's created inline. Otherwise the effect re-runs on every render, adding and removing the event listener constantly. Mostly harmless, occasionally causes weird timing bugs. We hit this once during a live demo and spent twenty minutes debugging before noticing the problem was just a missing `useCallback`.

Scroll Lock — Three Approaches, One Clear Winner

When a modal is open, the background page shouldn't scroll. The obvious approach is `document.body.style.overflow = 'hidden'`. It works. On desktop. On iOS Safari it does absolutely nothing because iOS ignores overflow on the body during momentum scrolling. The background scrolls anyway. This has been a known issue since roughly the Obama administration and Apple has simply chosen not to fix it.

  • `overflow: hidden` on body — works on desktop, broken on iOS
  • Saving and restoring `scrollY`, then using `position: fixed` on body with `top: -scrollY` — works everywhere but causes layout shift if the scrollbar disappears
  • Using `overscroll-behavior: contain` on the modal itself — modern, elegant, but doesn't fully prevent background scroll in all cases

The approach that actually works everywhere is the `position: fixed` trick. You save the current scroll position, fix the body, set `top` to the negative scroll offset so the page doesn't jump, then restore everything on close. It sounds hacky because it is, but it's what libraries like Radix UI use under the hood.

function useScrollLock(isOpen: boolean) {
  React.useEffect(() => {
    if (!isOpen) return;

    const scrollY = window.scrollY;
    const body = document.body;
    const originalStyle = {
      overflow: body.style.overflow,
      position: body.style.position,
      top: body.style.top,
      width: body.style.width,
    };

    body.style.overflow = 'hidden';
    body.style.position = 'fixed';
    body.style.top = `-${scrollY}px`;
    body.style.width = '100%';

    return () => {
      body.style.overflow = originalStyle.overflow;
      body.style.position = originalStyle.position;
      body.style.top = originalStyle.top;
      body.style.width = originalStyle.width;
      window.scrollTo(0, scrollY);
    };
  }, [isOpen]);
}

One edge case: if multiple modals can be open simultaneously (or a toast pops up while a modal is open), you need to reference-count the scroll lock instead of just toggling it. Otherwise the inner component unlocks scroll when it closes, even though an outer modal is still visible. Track a counter, not a boolean.

ARIA — The Minimum Viable Semantics

You don't need to be an accessibility expert to get the basics right. For a modal, it comes down to three attributes and a ref.

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const titleId = React.useId();
  const descriptionId = React.useId();

  useFocusTrap(containerRef, isOpen);
  useEscapeKey(onClose, isOpen);
  useScrollLock(isOpen);

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <>
      {/* Overlay */}
      <div
        className="fixed inset-0 bg-black/50"
        onClick={onClose}
        aria-hidden="true"
      />
      {/* Dialog */}
      <div
        ref={containerRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={descriptionId}
        className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 bg-white rounded-lg shadow-xl p-6 w-full max-w-md"
      >
        <h2 id={titleId} className="text-lg font-semibold">{title}</h2>
        <div id={descriptionId}>{children}</div>
        <button
          onClick={onClose}
          aria-label="Close dialog"
          className="absolute top-4 right-4"
        >
          ✕
        </button>
      </div>
    </>,
    document.body
  );
}

The `role="dialog"` tells screen readers this is a dialog. `aria-modal="true"` tells them to ignore everything outside it. `aria-labelledby` connects the dialog to its title, so screen readers announce the title when focus enters. Without these, a VoiceOver user hears nothing useful when the modal opens.

Notice the overlay has `aria-hidden="true"`. Screen readers shouldn't interact with the overlay — it's purely visual. If you leave that off, some screen readers will announce the overlay as a clickable element, which is confusing.

Portals — Why You Need Them and When You Don't

Rendering modals via `ReactDOM.createPortal` into `document.body` sidesteps stacking context issues. If your modal is inside a component that has `transform`, `filter`, or `will-change` applied, a `position: fixed` child won't be fixed relative to the viewport anymore — it'll be fixed relative to that transformed ancestor. This is one of CSS's most unintuitive quirks and it bites constantly.

Portals also keep `z-index` sane. Instead of playing `z-index: 9999` whack-a-mole throughout your CSS, the portal puts the modal at the top of the DOM tree where stacking order is straightforward.

If you're using Next.js App Router, portals still work — but make sure you're in a Client Component. Server Components can't use `ReactDOM.createPortal` or any of the browser APIs we're using here. This is an obvious constraint but easy to forget at 11pm.

Animation Without Breaking Everything

The cleanest approach for modal animations in 2025 is CSS transitions combined with conditional rendering — but with one tweak. Instead of conditionally rendering `null` when closed, you conditionally render with a `data-state` attribute and drive the animation from CSS. This way you can animate both enter and exit.

// Alternatively, using Framer Motion for cleaner exit animations
import { AnimatePresence, motion } from 'framer-motion';

export function AnimatedModal({ isOpen, onClose, title, children }: ModalProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  
  useFocusTrap(containerRef, isOpen);
  useEscapeKey(onClose, isOpen);
  useScrollLock(isOpen);

  return ReactDOM.createPortal(
    <AnimatePresence>
      {isOpen && (
        <>
          <motion.div
            className="fixed inset-0 bg-black/50"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
            aria-hidden="true"
          />
          <motion.div
            ref={containerRef}
            role="dialog"
            aria-modal="true"
            className="fixed left-1/2 top-1/2 z-50 bg-white rounded-lg shadow-xl p-6 w-full max-w-md"
            initial={{ opacity: 0, x: '-50%', y: '-45%' }}
            animate={{ opacity: 1, x: '-50%', y: '-50%' }}
            exit={{ opacity: 0, x: '-50%', y: '-45%' }}
            transition={{ duration: 0.15, ease: 'easeOut' }}
          >
            {children}
          </motion.div>
        </>
      )}
    </AnimatePresence>,
    document.body
  );
}

Keep animations short. 150-200ms is plenty. Modals that take 400ms to appear feel slow, not polished. Also respect `prefers-reduced-motion` — wrap your animation variants in a check or use Framer Motion's `useReducedMotion` hook. Some users get nauseous from motion. This is easy to handle and often completely skipped.

Should You Just Use Radix UI?

Honestly? Probably yes, unless you have a specific reason not to. Radix UI's `Dialog` component handles focus trapping, escape keys, scroll locking, ARIA attributes, and portal rendering out of the box. It's unstyled, so you bring your own Tailwind classes. It's what we use in most of our peal.dev templates because it gives you all the accessibility behavior for free and you spend your time on things that actually matter.

That said, understanding what's happening underneath is worth the time. When Radix's focus trap behaves unexpectedly with a third-party component, you need to know why. When scroll lock doesn't work on iOS, you need to know it's a known browser limitation, not a bug in your code. This post exists so you have that context.

  • Use Radix UI Dialog (or shadcn/ui which wraps it) for production apps — it's battle-tested
  • Roll your own only if you're building a design system or have very specific constraints
  • Either way, understand focus trapping and scroll lock — you'll need to debug them eventually
  • Always test with a keyboard. Close your trackpad for five minutes. You'll find the bugs fast.
The real test for any modal: open it, don't touch your mouse, and try to close it. Tab through it, press Escape, use your screen reader. If any of that is broken, your modal isn't done.

Modals are one of those components where cutting corners is invisible in most circumstances and catastrophic in some. The keyboard user who can't close your modal doesn't file a bug report — they just leave. Build them right the first time, or use a library that already did.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders