[React] FocusTrap으로 모달창 개선하기

Joowon Jang·2025년 9월 21일
1

React

목록 보기
19/19

Focus Trap

모달창이 열렸을 때와 같이 특정 상황에 사용자가 키보드 조작(Tab키 등)을 통해 특정 구역 안에서만 이동할 수 있도록 focus를 가두는 것

일반적으로 모달창, 팝업, 드롭다운 메뉴 등 특정 상호작용이 필요한 인터페이스에서 사용된다.
Focus Trap이 활성화되면 사용자는 Tab 키나 Shift + Tab 키를 눌러도 해당 구역 내의 버튼, 링크, 입력 필드 등 상호작용 가능한 요소들 사이에서만 초점을 이동할 수 있고, 그 바깥의 배경 콘텐츠로는 빠져나갈 수 없다.

왜 사용할까?

포커스 트랩은 주로 다음과 같은 두 가지 중요한 이유로 사용됩니다.

1. 사용자 경험(UX) 향상

사용자가 특정 작업을 완료하도록 유도하는 데 효과적이다.
예를 들어, "정말 삭제하시겠습니까?"와 같은 확인 모달창이 떴을 때, 사용자는 '예' 또는 '아니요' 버튼에만 집중해야 한다.
만약 포커스가 배경의 다른 요소로 이동한다면 사용자는 혼란을 겪고 의도치 않은 동작을 할 수 있다.

2. 웹 접근성(Accessibility) 확보

특히 키보드만으로 웹을 탐색하는 사용자나 스크린 리더 사용자에게 매우 중요하다.

키보드 사용자: 마우스를 사용하지 않는 사용자가 모달창이 열렸을 때 Tab 키를 눌러 배경의 다른 링크로 이동해 버리면, 현재 열린 모달창의 작업을 완료할 수 없게 된다.

스크린 리더 사용자: 시각 장애인 사용자는 화면의 내용을 소리로 듣는데, 포커스가 모달창을 벗어나 배경 콘텐츠로 이동하면 스크린 리더는 배경의 내용을 읽게 되어 어떤 상황에 놓여 있는지 파악하기 어려워 큰 혼란을 겪게 된다.

Focus Trap 구현하기

원리

  1. 창이 열릴 때, useRef를 통해 해당 창에 연결한 RefObject를 전달받음.
    (필요하다면, 열릴 때 포커스를 받을 요소의 Ref까지)

  2. 창이 열리기 전에 focus를 받고 있던 요소를 기억. (lastFocusedElement)

  3. querySelectorAll() 메서드를 사용해 창 내부의 요소들 중 focusable한 요소들을 모두 찾음. (focusableElements)

  4. focus() 메서드를 실행해서 initialFocusRef를 통해 전달받은 요소 혹은 focusableElements 중 가장 첫 번째 요소가 최초로 focus를 받도록 함.

  5. Tab키를 누를 때, focusableElements 중 마지막 요소에 focus가 위치하고 있다면, 첫 번째 요소로 focus를 이동하고, Shift + Tab을 누를 때 focusableElements 중 첫 번째 요소에 focus가 위치하고 있다면, 마지막 요소로 focus를 이동하도록 KeyDown eventListener를 설정

  6. 창을 닫을 때는 eventListener를 제거하고, lastFocusedElement에 다시 focus를 이동시킴.

useFocusTrap 커스텀훅

import { useEffect, useRef } from 'react';

interface UseFocusTrapProps {
  /** 포커스를 가둘 컨테이너 요소의 ref 객체 */
  dialogRef: React.RefObject<HTMLElement>;
  /** 훅의 활성화 상태 (예: 모달이 열려있는지 여부) */
  isOpen: boolean;
  /** 'Escape' 키를 누르거나 로직에 따라 호출될 닫기 함수 */
  onClose: () => void;
  /** 훅이 활성화될 때 초기에 포커스를 받을 요소의 ref (선택 사항) */
  initialFocusRef?: React.RefObject<HTMLElement>;
}

export const useFocusTrap = ({
  dialogRef,
  isOpen,
  onClose,
  initialFocusRef,
}: UseFocusTrapProps) => {
  const lastFocusedElement = useRef<Element | null>(null);

  useEffect(() => {
    const containerElement = dialogRef.current;
    if (!containerElement) return;

    if (isOpen) {
      lastFocusedElement.current = document.activeElement;

      const focusableElements = containerElement.querySelectorAll<HTMLElement>(
        'button, [href]:not(use), input, select, textarea, [tabindex]:not([tabindex="-1"])',
      );

      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      if (initialFocusRef?.current) {
        // 1. initialFocusRef가 있으면 최우선으로 포커스
        initialFocusRef.current.focus();
      } else {
        // 2. 없으면 containerElement에 포커스를 시도
        containerElement.focus();

        // 3. containerElement 포커스 시도 후, activeElement가 여전히 container가 아니라면
        //    (즉, 포커스가 실패했다면) firstElement에 포커스
        if (document.activeElement !== containerElement) {
          firstElement?.focus();
        }
      }

      const handleKeyDown = (event: KeyboardEvent) => {
        if (event.key === 'Escape') {
          onClose();
          return;
        }

        if (event.key === 'Tab') {
          if (!firstElement) {
            event.preventDefault();
            return;
          }

          if (event.shiftKey && document.activeElement === firstElement) {
            event.preventDefault();
            lastElement.focus();
          } else if (
            !event.shiftKey &&
            document.activeElement === lastElement
          ) {
            event.preventDefault();
            firstElement.focus();
          }
        }
      };

      containerElement.addEventListener('keydown', handleKeyDown);

      return () => {
        containerElement.removeEventListener('keydown', handleKeyDown);
      };
    } else if (lastFocusedElement.current) {
      (lastFocusedElement.current as HTMLElement).focus();
      lastFocusedElement.current = null;
    }
  }, [isOpen, onClose, dialogRef, initialFocusRef]);
};

커스텀 훅 사용하기

const dialogRef = useRef<HTMLDivElement>(null);
const initialFocusRef = useRef<HTMLButtonElemet>(null);

useFocusTrap({
  dialogRef,
  isOpen: !!type,
  onClose: handleClose,
  initialFocusRef,
});

return(
	<div> {/* overlay */}
		<div ref={dialogRef}> {/* 모달창 */}
			{/* ... */}
			<button ref={initialFocusRef} type="button">확인</button>
		</div>
	</div>
)
profile
깊이 공부하는 웹개발자

0개의 댓글