포커스 관리와 키보드 내비게이션

React에서 useRef로 포커스를 프로그래밍적으로 제어하는 방법, 포커스 트랩(Focus Trap) 구현, 방향키 내비게이션 패턴, 스킵 링크를 실전 코드로 설명합니다.

· 6 min read · PALDYN Team

지난 글에서 ARIA 속성과 시맨틱 HTML로 접근성을 챙기는 방법을 살펴봤다. 이번 글에서는 그중 가장 세심한 구현이 필요한 포커스 관리를 다룬다. 마우스 없이 키보드만으로 앱을 사용하는 경우, 포커스 위치가 곧 사용자의 현재 위치다.

포커스 관리가 필요한 상황

포커스를 코드로 직접 제어해야 하는 상황이 있다.

포커스 관리 개념

  • 모달 열기 → 모달 내 첫 번째 포커스 가능 요소로 이동
  • 모달 닫기 → 모달을 열었던 버튼으로 포커스 복원
  • 폼 에러 → 첫 번째 에러 필드로 포커스 이동
  • 라우팅 → 새 페이지의 h1 또는 메인 콘텐츠로 이동
  • 드롭다운 열기 → 첫 번째 옵션으로 포커스 이동

useRef로 포커스 제어

useRef로 DOM 요소에 직접 참조를 얻고 .focus()를 호출한다.

import { useRef, useEffect } from 'react';

function SearchPage() {
  const searchInputRef = useRef<HTMLInputElement>(null);

  // 페이지 마운트 시 검색창으로 포커스
  useEffect(() => {
    searchInputRef.current?.focus();
  }, []);

  return (
    <div>
      <h1>검색</h1>
      <input
        ref={searchInputRef}
        type="search"
        placeholder="검색어 입력"
      />
    </div>
  );
}

?.focus() optional chaining으로 null 체크를 간결하게 처리한다.

모달 포커스 이동 + 복원

function useModalFocus(isOpen: boolean) {
  const firstFocusRef = useRef<HTMLElement>(null);
  const triggerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 모달 열기: 트리거 기억 → 모달 첫 요소 포커스
      triggerRef.current = document.activeElement as HTMLElement;
      // 비동기로 처리해야 Portal이 DOM에 마운트된 후 실행
      requestAnimationFrame(() => {
        firstFocusRef.current?.focus();
      });
    } else {
      // 모달 닫기: 트리거로 포커스 복원
      triggerRef.current?.focus();
    }
  }, [isOpen]);

  return firstFocusRef;
}
function Modal({ isOpen, onClose, children }: ModalProps) {
  const firstFocusRef = useModalFocus(isOpen);
  if (!isOpen) return null;

  return createPortal(
    <div role="dialog" aria-modal="true">
      <button ref={firstFocusRef} onClick={onClose} aria-label="닫기">×</button>
      {children}
    </div>,
    document.getElementById('modal-root')!,
  );
}

포커스 트랩(Focus Trap)

모달이 열려 있을 때 Tab 키로 모달 밖으로 나가면 안 된다. 포커스가 모달 안에서만 순환하도록 잡아두는 것을 포커스 트랩이라고 한다.

직접 구현

function useFocusTrap(containerRef: React.RefObject<HTMLElement>, isActive: boolean) {
  useEffect(() => {
    if (!isActive || !containerRef.current) return;

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

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      const focusable = Array.from(
        container.querySelectorAll<HTMLElement>(focusableSelectors),
      );
      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last?.focus();  // Shift+Tab at 첫 요소 → 마지막으로
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first?.focus();  // Tab at 마지막 요소 → 첫 번째로
      }
    };

    container.addEventListener('keydown', handleKeyDown);
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, [containerRef, isActive]);
}

라이브러리 사용 (권장)

직접 구현은 엣지 케이스가 많다. focus-trap-react 라이브러리를 권장한다.

npm install focus-trap-react
import FocusTrap from 'focus-trap-react';

function Modal({ isOpen, onClose, children }: ModalProps) {
  if (!isOpen) return null;

  return createPortal(
    <FocusTrap
      focusTrapOptions={{
        initialFocus: '#close-btn',
        returnFocusOnDeactivate: true,
        escapeDeactivates: true,
        onDeactivate: onClose,
      }}
    >
      <div role="dialog" aria-modal="true">
        <button id="close-btn" onClick={onClose}>닫기</button>
        {children}
      </div>
    </FocusTrap>,
    document.getElementById('modal-root')!,
  );
}

키보드 내비게이션 패턴

방향키 내비게이션

메뉴, 탭, 라디오 그룹 등 Composite 위젯에서는 방향키로 항목 간 이동을 지원해야 한다.

function MenuList({ items }: { items: MenuItem[] }) {
  const [focusedIndex, setFocusedIndex] = useState(0);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      newIndex = (index + 1) % items.length;
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      newIndex = (index - 1 + items.length) % items.length;
    } else if (e.key === 'Home') {
      e.preventDefault();
      newIndex = 0;
    } else if (e.key === 'End') {
      e.preventDefault();
      newIndex = items.length - 1;
    }

    setFocusedIndex(newIndex);
    itemRefs.current[newIndex]?.focus();
  };

  return (
    <ul role="menu">
      {items.map((item, index) => (
        <li key={item.id} role="none">
          <button
            ref={(el) => { itemRefs.current[index] = el; }}
            role="menuitem"
            tabIndex={index === focusedIndex ? 0 : -1}  // Roving tabindex
            onKeyDown={(e) => handleKeyDown(e, index)}
            onClick={item.onClick}
          >
            {item.label}
          </button>
        </li>
      ))}
    </ul>
  );
}

Roving tabindex 패턴: 현재 포커스 아이템만 tabIndex=0, 나머지는 tabIndex=-1로 설정해 Tab 키가 그룹을 건너뛰게 한다.

스킵 링크

화면 상단에 “메인 콘텐츠로 건너뛰기” 링크를 두면 키보드 사용자가 반복되는 내비게이션을 매번 탐색하지 않아도 된다.

// App.tsx
function App() {
  return (
    <>
      <a href="#main-content" className="skip-link">
        메인 콘텐츠로 건너뛰기
      </a>
      <header>...</header>
      <nav>...</nav>
      <main id="main-content" tabIndex={-1}>
        {/* 콘텐츠 */}
      </main>
    </>
  );
}
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px 16px;
  z-index: 9999;
}

.skip-link:focus {
  top: 0;  /* 포커스 시 화면에 나타남 */
}

포커스 표시(visible focus)

키보드 사용자에게 현재 포커스 위치가 보여야 한다.

/* 포커스 링을 제거하지 말 것 */
button:focus {
  /* outline: none; ← 절대 이렇게 하면 안 됨 */
  outline: 2px solid #7ec8e3;
  outline-offset: 2px;
}

/* 마우스 클릭 시에는 포커스 링 숨기고 키보드에만 표시 */
button:focus:not(:focus-visible) {
  outline: none;
}

button:focus-visible {
  outline: 2px solid #7ec8e3;
  outline-offset: 2px;
}

포커스 관리는 React 접근성의 마지막 퍼즐 조각이다. 시맨틱 HTML + ARIA 속성 + 올바른 포커스 흐름이 갖춰지면 키보드만으로 앱을 완전히 사용할 수 있게 된다. 다음 글에서는 React의 애니메이션과 Error Boundary로 이어진다.


지난 글: React 접근성(a11y) 기초 — 스크린 리더와 시맨틱


읽어주셔서 감사합니다. 😊