Skip to main content
CodeRocket
Accessibilityhighkeyboard

Manage focus during dynamic interactions

rule · focus-management

Focus management ensures keyboard and screen reader users can follow dynamic content changes and interactions.

Code Example

TSX
import { useEffect, useRef } from 'react'
 
function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef<HTMLDivElement>(null)
  const triggerRef = useRef<HTMLElement | null>(null)
 
  useEffect(() => {
    if (isOpen) {
      // Store the element that opened the modal
      triggerRef.current = document.activeElement as HTMLElement
      // Focus the modal
      modalRef.current?.focus()
    } else if (triggerRef.current) {
      // Return focus when closing
      triggerRef.current.focus()
    }
  }, [isOpen])
 
  if (!isOpen) return null
 
  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}
    >
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  )
}

Why It Matters

Poor focus management leaves keyboard users stranded—they can't find new content, get trapped in closed modals, or lose their place entirely after interactions.

Key Principles

ScenarioFocus Should Move To
Modal opensFirst focusable element in modal
Modal closesElement that triggered the modal
SPA navigationMain content heading or container
Content deletedPrevious/next item or parent container
Form submittedSuccess message or error summary

SPA Navigation Focus

TSX
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
 
function MainContent({ children }) {
  const mainRef = useRef<HTMLElement>(null)
  const location = useLocation()
 
  useEffect(() => {
    // Focus main content on navigation
    mainRef.current?.focus()
  }, [location.pathname])
 
  return (
    <main ref={mainRef} tabIndex={-1}>
      {children}
    </main>
  )
}

Dynamic Content Updates

TSX
function TodoList({ todos, onDelete }) {
  const listRef = useRef<HTMLUListElement>(null)
  const [deletedIndex, setDeletedIndex] = useState<number | null>(null)
 
  const handleDelete = (index: number) => {
    setDeletedIndex(index)
    onDelete(index)
  }
 
  useEffect(() => {
    if (deletedIndex !== null) {
      // Focus next item, or previous, or the list itself
      const items = listRef.current?.querySelectorAll('button')
      const nextItem = items?.[deletedIndex] || items?.[deletedIndex - 1]
 
      if (nextItem) {
        nextItem.focus()
      } else {
        listRef.current?.focus()
      }
      setDeletedIndex(null)
    }
  }, [deletedIndex, todos])
 
  return (
    <ul ref={listRef} tabIndex={-1}>
      {todos.map((todo, index) => (
        <li key={todo.id}>
          {todo.text}
          <button onClick={() => handleDelete(index)}>Delete</button>
        </li>
      ))}
    </ul>
  )
}

Form Submission Focus

TSX
function ContactForm() {
  const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
  const statusRef = useRef<HTMLDivElement>(null)
 
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()
    try {
      await submitForm()
      setStatus('success')
    } catch {
      setStatus('error')
    }
  }
 
  useEffect(() => {
    if (status !== 'idle') {
      statusRef.current?.focus()
    }
  }, [status])
 
  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
 
      {status !== 'idle' && (
        <div
          ref={statusRef}
          tabIndex={-1}
          role={status === 'error' ? 'alert' : 'status'}
        >
          {status === 'success' ? 'Message sent!' : 'Please fix errors above'}
        </div>
      )}
 
      <button type="submit">Send</button>
    </form>
  )
}

Exceptions

  • Temporary or intentionally inert UI can be removed from the focus order, but only when the same state is also communicated clearly to assistive technology users.
  • A focus-management issue should be evaluated in the rendered interaction, not only from static markup, because route changes, overlays, and JS timing can change the real behavior.
  • If a component is both unlabeled and focus-broken, fix the stronger user-facing orientation problem first rather than reporting multiple secondary symptoms.

Standards

  • Align the implementation with W3C WAI: WCAG Overview and verify the rendered experience, not only the source code.
  • Align the implementation with MDN: Accessibility and verify the rendered experience, not only the source code.

Verification

Automated Checks

  • Use browser accessibility tooling, axe, Lighthouse, or equivalent automated checks against a representative rendered state.

Manual Checks

  • Open modal with keyboard → focus should be inside modal
  • Close modal → focus should return to trigger element
  • Navigate SPA routes → focus should move to new content
  • Delete list items → focus should remain logical