DEV Community

Cover image for Improving Accessibility – Modal
hritickjaiswal
hritickjaiswal

Posted on

Improving Accessibility – Modal

This year, I decided to intentionally improve how I approach accessibility in UI components.

Instead of chasing a “perfect” implementation, I’m focusing on iterative improvements—build, test, learn, and refine. In my experience, this leads to better engineering outcomes than over-designing upfront.

I started with a modal, since it’s common, deceptively simple, and easy to get wrong.

First Iteration Requirements

For the first pass, these were my non-negotiables:

  • Focus trapping (Tab / Shift + Tab)
  • Focus restoration on close
  • Move focus into the modal on open
  • role="dialog" and aria-modal="true"
  • Background inertness (inert / aria-hidden)
  • Escape key handling
  • Screen reader announcement timing
  • Visible focus outlines via CSS

Implementation

import { useEffect, useRef, useState, type ReactNode } from "react";

import styles from "./style.module.css";
import { createPortal } from "react-dom";

interface ModalProps {
  children: ReactNode;
  open: boolean;
  onClose: () => void;
}

function Modal({ children, onClose, open }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const bgFocusElement = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    const keydownHandler = (e: KeyboardEvent) => {
      const { key } = e;
      const focusableElements = modalRef.current?.querySelectorAll(
        'button, a, [href], input,textarea, select, [tabindex]:not([tabindex = "-1"])'
      );

      if (key === "Tab" && focusableElements?.length) {
        const firstFocusableElement = focusableElements[0];
        const lastFocusableElement =
          focusableElements[focusableElements.length - 1];

        if (e.shiftKey) {
          if (document.activeElement === firstFocusableElement) {
            e.preventDefault();
            (lastFocusableElement as HTMLButtonElement).focus();
          }
        } else {
          if (document.activeElement === lastFocusableElement) {
            e.preventDefault();
            (firstFocusableElement as HTMLButtonElement).focus();
          }
        }
      } else if (key === "Escape") {
        onClose();
      }
    };

    if (open) {
      document.addEventListener("keydown", keydownHandler);
      const focusableElements = modalRef.current?.querySelectorAll(
        'button, a, [href], input,textarea, select, [tabindex]:not([tabindex = "-1"])'
      );

      bgFocusElement.current = document.activeElement as HTMLButtonElement;

      if (focusableElements?.length) {
        (focusableElements[0] as HTMLButtonElement)?.focus();
      }
    }

    return () => {
      if (open) {
        document.removeEventListener("keydown", keydownHandler);
      }
    };
  }, [open]);

  useEffect(() => {
    if (open) {
      const rootElement = document.getElementById("root");

      if (rootElement) {
        rootElement.inert = true;
      }

      document.body.style.overflow = "hidden";
    } else {
      const rootElement = document.getElementById("root");

      if (rootElement) {
        rootElement.inert = false;
      }

      document.body.style.overflow = "";

      if (bgFocusElement.current) {
        bgFocusElement.current.focus();
        bgFocusElement.current = null;
      }
    }

    return () => {
      const rootElement = document.getElementById("root");
      if (rootElement) {
        rootElement.inert = false;
      }
      document.body.style.overflow = "";
    };
  }, [open]);

  if (!open) {
    return null;
  }

  return createPortal(
    <section
      onClick={onClose}
      aria-modal="true"
      role="dialog"
      className={styles.modalContainer}
      ref={modalRef}
      aria-label="Modal-Container"
    >
      <div
        onClick={(e) => e.stopPropagation()}
        className={styles.childrenContainer}
        aria-label="Modal-Child-Container"
      >
        {children}
      </div>
    </section>,
    document.body
  );
}

function ModalParent() {
  const [open, setOpen] = useState(false);

  const openModal = () => {
    setOpen(true);
  };

  const closeModal = () => {
    setOpen(false);
  };

  return (
    <article>
      <button className={styles.btn} onClick={openModal}>
        ModalParent
      </button>
      <div
        style={{
          width: 400,
          height: 1300,
          backgroundColor: "red",
        }}
      ></div>

      <Modal open={open} onClose={closeModal}>
        <div>
          <h1>Modal</h1>

          <button className={styles.btn}>Modal Button</button>

          <input type="text" />
        </div>
      </Modal>
    </article>
  );
}

export default ModalParent;

Enter fullscreen mode Exit fullscreen mode

Styles (Focus Visibility Matters)

.modalContainer {
  position: fixed;
  inset: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  background: rgba(0, 0, 0, 0.5);
}

.childrenContainer {
  background: #fff;
  padding: 1rem;
  border-radius: 4px;
  max-width: 768px;
  width: 95%;
}

button:focus,
input:focus {
  outline: 2px solid orange;
  outline-offset: 4px;
}
Enter fullscreen mode Exit fullscreen mode

Key Learnings

  • Focus management is not optional
  • Background inertness dramatically improves keyboard and screen reader behavior
  • Native semantics + minimal ARIA works better than over-annotating
  • Visual focus indicators matter more than expected

Hope anyone who reads it finds it useful and any suggestions for my next iteration is most welcome.

Top comments (0)