본문으로 바로가기
검색 (Ctrl+K)

접근성을 위한 포커스트랩 모달 만들기

·12분 읽기·
---
·
개발

포커스 트랩을 진입·순환·되돌리기·복귀 네 단계로 직접 만들어 보고, 트랩 두 개가 겹치면 왜 서로 포커스를 뺏는지, 스택으로 어떻게 해결하는지 Radix 소스와 함께 정리했습니다.

얼마 전 실제 서비스에서 포커스 트랩 때문에 화면이 멈추는 문제를 겪었습니다. 드롭다운 메뉴에서 항목을 눌러 알림 모달을 띄웠는데, 모달을 닫고 나니 화면 어디를 눌러도 반응하지 않았어요. 원인을 따라가 보니 메뉴와 모달이 각자 걸어 둔 포커스 트랩이 서로를 모르고 있었습니다.

그동안 포커스 트랩은 라이브러리가 알아서 해 주는 것이라고만 생각했습니다. 막상 문제가 생기니 이게 정확히 뭘 하는지, 왜 둘이 겹치면 깨지는지 설명할 수가 없더라고요. 그래서 이번 기회에 포커스 트랩을 처음부터 직접 만들어 보면서 정리해 보려고 합니다.

포커스 트랩이 무엇인지부터 정리하고, 만들 기능을 정한 다음 하나씩 구현해 보겠습니다. 이어서 트랩 두 개를 겹쳐 실제로 깨뜨리고, 스택으로 고친 결과를 영상으로 비교해 보겠습니다.


포커스 트랩에 대하여

포커스란

포커스는 지금 키보드 입력을 받는 요소입니다. 버튼에 포커스가 있으면 Enter가 그 버튼을 누르고, 입력창에 포커스가 있으면 타이핑이 거기에 들어가요. Tab을 누르면 포커스가 다음 요소로, Shift+Tab을 누르면 이전 요소로 옮겨 갑니다. 마우스 없이 키보드만 쓰는 사람에게는 Tab이 곧 마우스 커서예요.

포커스 트랩이란

포커스 트랩은 Tab으로 움직이는 포커스가 정해진 영역 밖으로 못 나가게 가두는 것입니다. 영역 끝에서 Tab을 누르면 밖으로 나가지 않고 영역의 처음으로 돌아와요. 확인창, 모달, 드롭다운 메뉴처럼 "지금은 이것만 보세요" 하고 화면 위에 뜨는 것들이 이것을 사용합니다.

확인창에 포커스 트랩을 적용하기 전과 후를 비교하면 다음과 같습니다.

적용 전

트랩이 없을 때 Tab을 누르면 포커스가 확인창 밖으로 나가는 모습
확인창을 연 상태에서 Tab을 계속 누릅니다. 화면 아래 표시줄이 "취소 → 삭제 → 메뉴 · 대시보드 → …"로 바뀌고, 확인창을 벗어나는 순간 빨간색으로 "맨 위 확인창 밖으로 빠져나감"이 뜹니다.

적용 후

트랩을 걸면 Tab을 눌러도 확인창 안에서만 포커스가 도는 모습
같은 확인창에서 Tab을 계속 누릅니다. 표시줄이 "취소 → 삭제 → 취소 → 삭제"로만 돌고 빨간색이 한 번도 뜨지 않습니다.

왜 필요할까?

적용 전 영상처럼 확인창이 화면을 덮고 있어도, Tab을 몇 번 누르면 포커스는 뒤쪽 사이드바 메뉴, 검색창, 표의 버튼으로 넘어갑니다.

눈으로 보는 사람은 "뒤에 뭐가 있든 상관없지" 싶을 수 있어요. 하지만 키보드만 쓰는 사람이나 스크린 리더 사용자는 보이지 않는 버튼을 누르게 됩니다. 확인창 뒤 표의 "삭제" 버튼에서 Enter를 누르면 다른 주문이 지워질 수도 있어요.

aria-modal이면 되지 않나?

aria-modal="true"를 붙이면 될 것 같지만 그렇지 않습니다. aria-modal은 스크린 리더에게 "이 바깥은 무시해도 된다"고 알려 주는 표시일 뿐입니다. Tab 키 이동은 막지 않습니다. 키보드를 가두는 일은 직접 해야 해요.


구현할 기능

이번에 만들 포커스 트랩은 다음 다섯 가지를 지켜야 합니다.

  • 확인창이 열리면 확인창 안의 첫 버튼으로 포커스가 이동한다
  • 마지막 버튼에서 Tab을 누르면 첫 버튼으로, 첫 버튼에서 Shift+Tab을 누르면 마지막 버튼으로 돌아온다
  • 바깥을 클릭해도 포커스가 확인창 안으로 돌아온다
  • 확인창을 닫으면 열기 전에 있던 자리로 포커스가 돌아간다
  • 확인창 위에 확인창이 하나 더 떠도 두 트랩이 서로 싸우지 않는다

앞의 네 가지는 트랩 하나가 하는 일이고, 마지막 하나는 트랩이 여러 개일 때 필요한 일입니다. 트랩 하나가 하는 일을 정리하면 다음과 같습니다.

단계하는 일쓰는 것
1. 진입열리면 확인창 안 첫 버튼으로 포커스를 옮긴다element.focus()
2. 순환마지막에서 Tab을 누르면 처음으로, 처음에서 Shift+Tab을 누르면 마지막으로keydown과 preventDefault()
3. 되돌리기마우스 클릭 등으로 포커스가 밖에 나가면 다시 안으로 데려온다focusin
4. 복귀닫히면 열기 전에 있던 자리로 포커스를 돌려준다열 때 document.activeElement 기억
포커스 트랩이 하는 네 가지 일

다 만들고 나면 확인창 컴포넌트에서는 훅 한 줄만 부르면 돼요.

typescript
const ConfirmDialog = ({ onClose }: { onClose: () => void }) => {
  const ref = useRef<HTMLDivElement>(null);
  useFocusTrap(ref, true);

  return (
    <div ref={ref} role="alertdialog" aria-modal="true">
      <h2>출고주문 삭제</h2>
      <button onClick={onClose}>취소</button>
      <button onClick={onClose}>삭제</button>
    </div>
  );
};

포커스 트랩 구현

0단계: Tab으로 갈 수 있는 요소 찾기

먼저 확인창 안에서 Tab으로 갈 수 있는 요소를 모으는 함수가 필요합니다. 어떤 요소가 Tab으로 포커스를 받는지는 브라우저가 정해 두었습니다.

요소Tab으로 갈 수 있나
링크 <a href>갈 수 있음
<button>, <input>, <select>, <textarea>갈 수 있음. 단, disabled면 건너뜀
tabindex="0"을 붙인 요소갈 수 있음. <div>도 이걸 붙이면 들어감
tabindex="-1"을 붙인 요소못 감. 코드로 focus()를 부를 때만 포커스를 받음

이 규칙을 CSS 선택자로 옮기면 다음과 같습니다.

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

const getTabbables = (container: HTMLElement) =>
  Array.from(container.querySelectorAll<HTMLElement>(TABBABLE));

설명용이라 단순하게 만들었어요. 실제 라이브러리는 숨겨진 요소(display: none), tabindex 순서까지 따집니다.

1단계: 진입

확인창이 떠도 포커스는 저절로 옮겨 가지 않습니다. 사용자가 표의 "삭제" 버튼을 눌러 확인창을 열었다면 포커스는 여전히 그 "삭제" 버튼에 있습니다. 이 상태에서 Enter를 한 번 더 누르면 확인창 뒤의 버튼이 다시 눌려요. 스크린 리더도 확인창이 떴다는 사실을 모른 채 뒤쪽 화면을 계속 읽게 돼요. 그래서 트랩은 열리자마자 포커스를 확인창 안으로 데려와야 합니다.

0단계에서 만든 getTabbables로 확인창 안의 요소를 모으고, 그중 첫 번째 요소에 focus()를 부릅니다.

typescript
export const useFocusTrap = (
  containerRef: RefObject<HTMLElement | null>,
  active: boolean,
) => {
  useEffect(() => {
    const container = containerRef.current;
    if (!active || !container) return;

    getTabbables(container)[0]?.focus();
  }, [active, containerRef]);
};
  • useEffect 안에서 실행하는 이유는 확인창이 화면에 그려진 뒤에야 containerRef.current에 실제 DOM 요소가 들어오기 때문입니다. 렌더링 도중에는 아직 버튼이 없어서 포커스를 옮길 수 없어요.
  • active가 false이거나 컨테이너가 없으면 아무것도 하지 않습니다. 확인창이 닫혀 있을 때는 트랩도 꺼져 있어야 하기 때문입니다.
  • [0]?.focus()는 첫 번째 요소가 있을 때만 포커스를 옮깁니다. 포커스를 받을 요소가 하나도 없는 확인창이라면 그냥 넘어가요.

영상 속 확인창에서는 열리자마자 "취소" 버튼에 포커스가 갑니다. 삭제처럼 되돌릴 수 없는 동작을 묻는 창에서는 실수로 Enter를 눌러도 안전한 "취소"가 먼저 오는 편이 좋아요.

2단계: 순환

브라우저의 Tab은 문서 전체를 기준으로 다음 요소로 이동합니다. 확인창의 마지막 버튼에서 Tab을 누르면 브라우저는 문서에서 그다음 요소를 찾고, 그 요소는 확인창 밖에 있어요. 확인창 안에서 계속 돌게 하려면 끝에 닿았을 때 브라우저 대신 직접 포커스를 옮겨야 합니다.

Tab 키가 눌릴 때마다 지금 포커스가 확인창의 처음이나 끝에 있는지 확인합니다. 끝에 있을 때만 브라우저의 기본 동작을 preventDefault()로 막고, 반대쪽 끝으로 직접 옮깁니다.

typescript
const handleKeyDown = (event: KeyboardEvent) => {
  if (event.key !== "Tab") return;
  const tabbables = getTabbables(container);
  const first = tabbables[0];
  const last = tabbables[tabbables.length - 1];
  if (!first || !last) return;

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

document.addEventListener("keydown", handleKeyDown);
  • event.key !== "Tab"이면 바로 빠져나갑니다. 다른 키는 트랩이 신경 쓸 일이 아니에요.
  • getTabbables(container)를 키를 누를 때마다 다시 부릅니다. 확인창 안의 버튼이 중간에 생기거나 disabled로 바뀔 수 있기 때문에, 열 때 한 번 모아 둔 목록을 쓰면 틀릴 수 있어요.
  • document.activeElement는 지금 포커스를 가진 요소입니다. 이것이 마지막 요소이고 Shift 없이 Tab을 눌렀다면 첫 요소로 보냅니다.
  • 반대로 첫 요소에서 Shift+Tab을 눌렀다면 마지막 요소로 보냅니다.
  • preventDefault()를 빼먹으면 직접 옮긴 뒤에 브라우저가 기본 동작을 한 번 더 해서 포커스가 엉뚱한 곳으로 가요.

처음과 끝이 아닐 때는 아무것도 하지 않습니다. "취소"에서 "삭제"로 가는 것처럼 확인창 안에서의 이동은 브라우저가 이미 잘 해 주기 때문에 맡겨 둬요.

3단계: 되돌리기

포커스가 밖으로 나가는 길은 Tab만 있는 것이 아닙니다.

  • 마우스로 확인창 뒤의 검색창을 클릭하면 포커스가 검색창으로 갑니다.
  • 다른 코드가 뒤쪽 요소에 focus()를 부를 수도 있습니다. 예를 들어 알림 토스트가 뜨면서 자기 버튼에 포커스를 가져가는 경우예요.
  • 브라우저 주소창에 갔다가 Tab으로 다시 페이지에 들어오면 문서의 첫 요소부터 시작합니다.

이런 경우를 하나하나 막을 수는 없어요. 대신 "포커스가 어디로 옮겨 갔는지"를 지켜보다가, 확인창 밖이면 다시 데려옵니다.

포커스가 어떤 요소에 들어올 때마다 focusin 이벤트가 발생합니다. 이 이벤트를 document에서 듣고, 이벤트가 일어난 요소(event.target)가 확인창 안에 있는지 확인합니다.

typescript
const handleFocusIn = (event: FocusEvent) => {
  if (!container.contains(event.target as Node)) {
    getTabbables(container)[0]?.focus();
  }
};

document.addEventListener("focusin", handleFocusIn);
  • container.contains(event.target)는 포커스를 받은 요소가 확인창 안에 있는지 확인합니다. 안에 있으면 정상이니까 아무것도 하지 않아요.
  • 밖에 있으면 확인창의 첫 요소로 포커스를 다시 옮깁니다.

비슷한 이벤트로 focus도 있는데 여기서는 focusin을 씁니다. focus 이벤트는 버블링되지 않아서, 포커스를 받은 그 요소에서만 발생하고 document까지 올라오지 않습니다. focusin은 버블링되기 때문에 document 하나에만 리스너를 달아 두면 페이지 어디에 포커스가 가든 알 수 있어요.

이 리스너는 뒤에서 트랩 두 개가 겹칠 때 다시 나옵니다.

4단계: 복귀

포커스를 가진 요소가 DOM에서 사라지면 포커스는 body로 떨어집니다. 확인창을 닫으면 그 안의 버튼도 함께 사라지기 때문에 포커스는 body로 갑니다. 그러면 키보드 사용자는 자기가 어디에 있었는지 잃어버리고, Tab을 처음부터 다시 눌러 표의 원래 줄까지 찾아가야 해요.

트랩이 켜질 때 지금 포커스가 있는 요소를 기억해 두고, 트랩이 꺼질 때 그 요소로 포커스를 돌려줍니다.

typescript
const previouslyFocused = document.activeElement as HTMLElement | null;
// ... 진입, 리스너 등록 ...

return () => {
  document.removeEventListener("keydown", handleKeyDown);
  document.removeEventListener("focusin", handleFocusIn);
  previouslyFocused?.focus();
};
  • previouslyFocused는 반드시 1단계 진입보다 먼저 저장해야 합니다. 진입이 포커스를 확인창 안으로 옮긴 다음에 저장하면 "취소" 버튼이 저장되기 때문이에요.
  • useEffect가 돌려주는 함수(cleanup)는 active가 false로 바뀌거나 확인창이 화면에서 사라질 때 실행됩니다. 트랩을 끄는 일을 여기서 해요.
  • 리스너를 먼저 떼고 나서 포커스를 돌려줍니다. 순서가 반대이면 previouslyFocused.focus()가 일으킨 focusin을 아직 남아 있는 3단계 리스너가 받아서 "밖이네?" 하고 포커스를 다시 확인창으로 끌고 가려고 해요.

이제 확인창을 닫으면 포커스가 확인창을 열었던 표의 "삭제" 버튼으로 돌아옵니다.

전체 코드

typescript
export const useFocusTrap = (
  containerRef: RefObject<HTMLElement | null>,
  active: boolean,
) => {
  useEffect(() => {
    const container = containerRef.current;
    if (!active || !container) return;

    // 4. 복귀를 위해 열기 전 포커스 위치를 기억한다.
    const previouslyFocused = document.activeElement as HTMLElement | null;

    // 1. 진입: 첫 요소로 이동
    getTabbables(container)[0]?.focus();

    // 2. 순환: Tab이 끝에 닿으면 반대쪽 끝으로
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "Tab") return;
      const tabbables = getTabbables(container);
      const first = tabbables[0];
      const last = tabbables[tabbables.length - 1];
      if (!first || !last) return;

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

    // 3. 되돌리기: 밖으로 나간 포커스를 다시 안으로
    const handleFocusIn = (event: FocusEvent) => {
      if (!container.contains(event.target as Node)) {
        getTabbables(container)[0]?.focus();
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    document.addEventListener("focusin", handleFocusIn);
    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      document.removeEventListener("focusin", handleFocusIn);
      // 4. 복귀: 원래 자리로 돌려주기
      previouslyFocused?.focus();
    };
  }, [active, containerRef]);
};

이 훅으로 확인창 하나는 가둘 수 있습니다. 앞에서 본 "적용 후" 영상이 이 훅을 건 결과예요.


트랩이 겹치면 생기는 문제

확인창 위에 확인창

이제 확인창 안의 "삭제"를 누르면 2차 확인창 "정말 삭제할까요?"가 하나 더 뜨게 바꿔 보겠습니다. 두 확인창 모두 같은 useFocusTrap을 사용합니다. 코드는 이게 전부예요.

typescript
const [target, setTarget] = useState<string | null>(null);
const [recheck, setRecheck] = useState(false);

{
  target ? (
    <ConfirmDialog
      title="출고주문 삭제"
      onCancel={closeAll}
      onConfirm={() => setRecheck(true)} // 2차 확인창 열기
    />
  ) : null;
}
{
  target && recheck ? (
    <ConfirmDialog
      title="정말 삭제할까요?"
      onCancel={() => setRecheck(false)}
      onConfirm={closeAll}
    />
  ) : null;
}

각 확인창만 보면 잘 만든 트랩입니다. 그런데 2차 확인창이 뜨는 순간 화면이 멈춥니다. 두 트랩이 포커스를 서로 끌어오기 시작하기 때문이에요.

트랩 두 개가 포커스를 서로 끌어오는 모습
1차 확인창에서 "삭제"를 누릅니다. 2차 확인창이 뜨자마자 위쪽에 빨간 배너 "두 트랩이 포커스를 40번 넘게 서로 뺏어서 강제로 멈췄습니다"가 뜹니다. Tab을 누를 때마다 배너의 횟수가 올라갑니다.

왜 서로 끌어올까

원인은 3단계에서 만든 되돌리기 리스너입니다. 2차 확인창이 떠도 1차 확인창은 닫히지 않았으니까, 1차 트랩의 focusin 리스너도 여전히 살아 있습니다. 두 리스너는 정반대의 일을 해요.

  • 1차 트랩: "포커스가 1차 확인창 밖이네? 1차 안으로 데려와야지"
  • 2차 트랩: "포커스가 2차 확인창 밖이네? 2차 안으로 데려와야지"

포커스는 한 군데에만 있을 수 있으니, 어디에 있든 둘 중 하나는 반드시 "밖"입니다. 그리고 focus()는 호출하는 그 자리에서 곧바로 focusin을 다시 일으킵니다. 그래서 호출이 끝나기 전에 안쪽에서 다음 호출이 일어나요.

리스너 안에서 focus()를 부르고, 그게 또 리스너를 부르니까 함수 호출이 끝나지 않고 계속 쌓입니다.

focus()와 focusin이 번갈아 안쪽으로 쌓이는 모습

트랩 하나하나는 맞게 동작해요. 트랩끼리 서로의 존재를 모르는 게 문제입니다.

스택으로 해결하기

트랩들이 함께 쓰는 목록(스택)을 하나 두고, 규칙을 하나 정합니다.

맨 위 트랩만 일하고, 아래 트랩은 쉰다.
  • 새 트랩이 열리면 지금 맨 위 트랩을 paused로 만들고 자기를 맨 위에 올립니다.
  • 트랩이 닫히면 자기를 목록에서 빼고, 새로 맨 위가 된 트랩을 다시 깨웁니다.
  • 쉬는 트랩의 리스너는 아무것도 하지 않아요.

바뀐 부분만 보면 다음과 같습니다.

typescript
interface Trap {
  paused: boolean;
}

/** 열려 있는 트랩 목록 값. 마지막 원소가 맨 위 트랩이다. */
const trapStack: Trap[] = []; // ← 모듈 맨 위, 모든 트랩이 같이 쓴다

export const useFocusTrap = (
  containerRef: RefObject<HTMLElement | null>,
  active: boolean,
  { stack = false }: { stack?: boolean } = {},
) => {
  useEffect(() => {
    const container = containerRef.current;
    if (!active || !container) return;

    const trap: Trap = { paused: false };
    if (stack) {
      // 새 트랩이 뜨면 아래 트랩은 쉰다.
      const below = trapStack[trapStack.length - 1];
      if (below) below.paused = true;
      trapStack.push(trap);
    }

    // ... 진입은 그대로 ...

    const handleKeyDown = (event: KeyboardEvent) => {
      if (trap.paused || event.key !== "Tab") return; // ← 쉬는 중이면 무시
      // ... 그대로 ...
    };

    const handleFocusIn = (event: FocusEvent) => {
      if (trap.paused) return; // ← 쉬는 중이면 무시
      // ... 그대로 ...
    };

    // ... 리스너 등록 ...
    return () => {
      // ... 리스너 해제 ...
      if (stack) {
        // 내가 빠지면 바로 아래 트랩이 다시 일한다.
        trapStack.splice(trapStack.indexOf(trap), 1);
        const below = trapStack[trapStack.length - 1];
        if (below) below.paused = false;
      }
      previouslyFocused?.focus();
    };
  }, [active, containerRef, stack]);
};

스택이 있을 때와 없을 때를 비교하려고 stack 옵션으로 켜고 끌 수 있게 했습니다. 실제로 쓴다면 옵션 없이 항상 켜 두면 돼요.

스택을 적용하면 2차 확인창이 열릴 때 1차 트랩은 쉬고 있어서 focusin을 그냥 통과시켜요. 2차가 닫히면 1차가 다시 깨어나고, 포커스는 2차를 열었던 "삭제" 버튼으로 돌아옵니다.

맨 위 트랩만 일하는 스택

스택을 적용한 뒤 같은 상황을 다시 만들어 보면 다음과 같습니다.

스택을 적용하면 2차 확인창 안에서만 포커스가 도는 모습
1차 확인창에서 "삭제"를 눌러 2차 확인창을 띄웁니다. 배너는 뜨지 않고, Tab을 누르면 2차 확인창의 "취소 → 삭제"만 돕니다.

마치며

  • 포커스 트랩은 진입·순환·되돌리기·복귀 네 가지면 만들 수 있습니다.
  • aria-modal은 키보드를 막지 않습니다. Tab과 focusin은 직접 처리해야 합니다.
  • 트랩이 겹치면 "내 밖이면 데려와" 리스너끼리 포커스를 끝없이 뺏습니다.
  • 그래서 트랩들은 스택 하나를 함께 쓰고, 맨 위 트랩만 일합니다. Radix UI의 focusScopesStack도 같은 구조입니다. 자세한 내용은 아래 번외에 정리해 뒀어요.

그런데 스택 코드에서 한 줄을 다시 보겠습니다.

typescript
const trapStack: Trap[] = []; // ← 모듈 맨 위, 모든 트랩이 같이 쓴다

이 스택이 동작하는 것은 모든 트랩이 같은 파일의 같은 변수를 보고 있기 때문입니다. 만약 이 파일이 두 벌 있다면 어떨까요? 예를 들어 node_modules에 라이브러리의 포커스 트랩 패키지가 서로 다른 버전으로 두 개 설치되어서, Dialog는 한쪽을, DropdownMenu는 다른 쪽을 사용하는 경우입니다.

한 벌일 때와 두 벌일 때

스택이 두 개가 되고, 트랩들은 다시 서로를 모르게 돼요. 앞에서 본 문제가 라이브러리 안에서 그대로 생겨요. 코드는 그대로인데 패키지를 설치한 시점이 달라서 생기는 문제입니다.

다음 편에서는 이 일이 실제로 어떻게 생기는지, 그리고 어떻게 막는지 다뤄 보겠습니다.


번외: 라이브러리는 어떻게 할까?

직접 만든 이 구조가 라이브러리에도 있을까요? Radix UI의 Dialog, DropdownMenu 같은 컴포넌트는 내부에서 @radix-ui/react-focus-scope라는 패키지로 포커스를 가둡니다. 그 빌드 파일(dist/index.mjs)을 열어 보면 거의 같은 코드가 나옵니다.

javascript
var focusScopesStack = createFocusScopesStack();

function createFocusScopesStack() {
  let stack = [];
  return {
    add(focusScope) {
      const activeFocusScope = stack[0];
      if (focusScope !== activeFocusScope) activeFocusScope?.pause();
      stack = arrayRemove(stack, focusScope);
      stack.unshift(focusScope);
    },
    remove(focusScope) {
      stack = arrayRemove(stack, focusScope);
      stack[0]?.resume();
    },
  };
}

add가 우리의 "아래 트랩 쉬게 하고 맨 위에 올리기", remove가 "빠지고 아래 트랩 깨우기"입니다. 맨 위를 배열 끝(push)이 아니라 앞(unshift)에 두는 것만 달라요.

되돌리기 리스너도 같습니다.

javascript
handleFocusIn = function (event) {
  if (focusScope.paused || !container) return;
  if (container.contains(event.target))
    lastFocusedElementRef.current = event.target;
  else focus(lastFocusedElementRef.current, { select: true });
};

paused면 바로 빠져나갑니다. 하나 다른 점은 밖으로 나간 포커스를 "첫 요소"가 아니라 마지막으로 포커스가 있던 요소로 돌려준다는 것입니다. 사용자가 "삭제"에 있다가 밖을 클릭했으면 "삭제"로 돌아와요.

이 밖에도 Radix는 우리 50줄짜리 훅이 안 다루는 것들을 챙깁니다.

우리 훅Radix FocusScope
선택자로 tabbable 찾기TreeWalker로 찾고 숨겨진 요소 제외
document에 keydown컨테이너에 onKeyDown, Alt·Ctrl·Meta 조합은 무시
포커스 갈 곳이 없으면 아무것도 안 함컨테이너 자체(tabIndex=-1)에 포커스
밖이면 첫 요소로마지막 위치로, focusout도 함께 처리
-포커스된 요소가 DOM에서 지워지면 MutationObserver로 감지해 컨테이너로
열고 닫을 때 무조건 이동onMountAutoFocus·onUnmountAutoFocus로 막을 수 있음

그래도 진입, 순환, 되돌리기, 복귀, 스택이라는 뼈대는 같습니다.