typescript css 135 lines · 3 tabs

Trap Keyboard Focus in a Modal with a useFocusTrap React Hook

Shared by codesnips Jul 2026
3 tabs
import { useEffect, useRef } from "react";

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

function getFocusable(container: HTMLElement): HTMLElement[] {
  const nodes = Array.from(
    container.querySelectorAll<HTMLElement>(FOCUSABLE)
  );
  return nodes.filter((el) => el.offsetParent !== null);
}

export function useFocusTrap<T extends HTMLElement>(active: boolean) {
  const ref = useRef<T>(null);

  useEffect(() => {
    const container = ref.current;
    if (!active || !container) return;

    const previouslyFocused = document.activeElement as HTMLElement | null;
    const initial = getFocusable(container);
    (initial[0] ?? container).focus();

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key !== "Tab" || !container) return;
      const focusable = getFocusable(container);
      if (focusable.length === 0) {
        event.preventDefault();
        return;
      }
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      const activeEl = document.activeElement;

      if (event.shiftKey && activeEl === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && activeEl === last) {
        event.preventDefault();
        first.focus();
      }
    }

    document.addEventListener("keydown", handleKeyDown);
    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      previouslyFocused?.focus();
    };
  }, [active]);

  return ref;
}
3 files · typescript, css Explain with highlit

A modal dialog that leaves keyboard focus loose is inaccessible: pressing Tab escapes into the page behind the overlay, screen-reader users lose their place, and the WCAG requirement that focus stay within the dialog is violated. The fix is a focus trap that cycles Tab and Shift+Tab between the first and last focusable elements, moves focus into the dialog on open, and restores it to the trigger on close. This example splits that behavior into a reusable useFocusTrap hook and a Modal component that consumes it.

In useFocusTrap hook, the hook returns a ref that the caller attaches to the dialog container. A getFocusable helper queries the container for the standard set of tabbable selectors and filters out elements hidden via offsetParent being null, since a querySelectorAll match can still be visually removed. The effect only runs when active is true, so an inactive modal installs no listeners.

The core logic lives in the keydown handler. It ignores everything except Tab, then recomputes the focusable list on each press rather than caching it — this matters because dialog content is often dynamic, and a stale list would let focus land on a removed node. When Shift+Tab is pressed on the first element it wraps to the last, and a forward Tab on the last wraps to the first, each time calling preventDefault to stop the browser's native traversal.

Before wiring listeners, the effect captures document.activeElement as previouslyFocused, then focuses either the container's first focusable child or the container itself. The cleanup function removes the listener and, critically, calls previouslyFocused.focus() so closing the modal returns the user to the button that opened it.

In Modal component, the hook is combined with a portal via createPortal, an Escape-key handler through onKeyDown, role="dialog" and aria-modal="true" for assistive tech, and a labelled title. The component renders nothing when isOpen is false, so the trap tears down cleanly.

One pitfall to note: this traps Tab but does not itself hide background content from screen readers — pairing it with aria-hidden or the inert attribute on the rest of the page completes the pattern. The focus-visible.css tab keeps focus rings visible only for keyboard users.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Semantic HTML Example</title>

Semantic HTML5 elements and accessibility best practices

html html5 semantics
by Alex Chang 2 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Form Validation Example</title>
  <style>

HTML forms with validation and accessibility

html forms validation
by Alex Chang 1 tab
javascript
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"

const application = Application.start()
application.debug = false

Disable submit button while Turbo form is submitting

rails hotwire stimulus
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Trap Keyboard Focus in a Modal with a useFocusTrap React Hook — share card
Link copied