typescript css 168 lines · 4 tabs

Accessible modal with focus trap

Shared by codesnips Jan 2026
4 tabs
import { useEffect, RefObject } from "react";

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

export function useFocusTrap(
  ref: RefObject<HTMLElement>,
  active: boolean
): void {
  useEffect(() => {
    if (!active || !ref.current) return;

    const container = ref.current;
    const previouslyFocused = document.activeElement as HTMLElement | null;

    const getTabbable = (): HTMLElement[] =>
      Array.from(container.querySelectorAll<HTMLElement>(TABBABLE)).filter(
        (el) => el.offsetParent !== null
      );

    const tabbable = getTabbable();
    (tabbable[0] ?? container).focus();

    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key !== "Tab") return;
      const items = getTabbable();
      if (items.length === 0) return;

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

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };

    container.addEventListener("keydown", onKeyDown);
    return () => {
      container.removeEventListener("keydown", onKeyDown);
      previouslyFocused?.focus();
    };
  }, [ref, active]);
}
4 files · typescript, css Explain with highlit

An accessible dialog needs more than a positioned div: it must trap keyboard focus, restore focus when it closes, respond to the Escape key, and expose the right ARIA roles so screen readers announce it as a modal. This snippet splits that responsibility across a reusable focus-trap hook, a small scroll-lock hook, and the Modal component that wires them together.

useFocusTrap hook is the core of the pattern. When active it records document.activeElement so focus can be returned later, then queries the container for tabbable elements using a selector that excludes disabled controls and tabindex="-1". Initial focus moves to the first tabbable node so keyboard users start inside the dialog. The keydown handler intercepts Tab and Shift+Tab at the boundaries, wrapping from last to first and vice versa; this is what keeps focus from escaping to the page behind the overlay. On cleanup it calls previouslyFocused.focus(), which is why triggering a modal and closing it returns the user to the button that opened it.

useScrollLock hook addresses a subtler problem: without it, scrolling the page behind the overlay is still possible, and removing the scrollbar causes a layout shift. It measures the scrollbar width via innerWidth - documentElement.clientWidth and compensates with paddingRight while setting overflow: hidden, then restores the original inline styles on unmount. Capturing the previous values rather than hardcoding empty strings avoids clobbering styles set elsewhere.

Modal component composes the two hooks and adds the semantic layer. It renders through createPortal into document.body so the dialog is not constrained by parent overflow or z-index stacking contexts. The container carries role="dialog", aria-modal="true", and aria-labelledby pointing at the title, giving assistive technology the information it needs. Clicking the backdrop closes, but stopPropagation on the panel prevents inner clicks from bubbling up and dismissing it. Rendering null when open is false keeps the trap and lock hooks from running needlessly.

The main pitfall this design avoids is scattering focus logic inside the component body; keeping it in useFocusTrap makes it testable and reusable across dialogs, drawers, and popovers.


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.

Accessible modal with focus trap — share card
Link copied