본문으로 건너뛰기
fe.run

tabindex 값 하나가 순서를 뒤집을 때

글 복사 완료!

Tab 을 눌렀는데 포커스가 엉뚱한 데서 시작하는 이유를 짚어요.

·17분·

디자인 시안 순서에 맞추려고 tabindex="1" 을 몇 개 심어둔 적이 있어요. 그런데 Tab 을 눌러보니 포커스가 페이지 맨 위가 아니라 한복판부터 시작하더라고요. 양수 tabindex 는 자기 순서만 바꾸는 게 아니라 페이지의 나머지 전부를 자기 뒤로 밀어내고, 그래서 실무에서 손에 쥘 값은 0-1 두 개뿐이에요.

탭을 눌렀는데 엉뚱한 데로 갑니다

키보드만으로 페이지를 쓰는 상황은 생각보다 자주 옵니다. 마우스가 고장 났을 수도 있고, 스크린 리더를 쓸 수도 있고, 그냥 손이 키보드에서 떨어지기 싫을 수도 있어요. 접근성이 왜 특별한 기능이 아닌지는 예전 글에서 한 번 정리했는데, 그 중에서 키보드 탐색의 뼈대를 잡는 게 tabindex 예요.

아래 데모의 첫 칸을 클릭한 다음 Tab 을 계속 눌러보세요. 버튼을 눌러 양수 tabindex 를 껐다 켜면 순서가 어떻게 달라지는지 기록에 남습니다.

양수를 켜두면 제출 버튼과 이메일 칸이 상단 버튼들보다도 먼저 잡힙니다. 시안 순서를 맞추려고 두 곳만 건드렸는데 페이지 전체 순서가 흔들린 거예요. 이건 취향 문제가 아니라 WCAG, 그러니까 웹 콘텐츠 접근성 지침이 명시적으로 실패 사례로 적어둔 패턴입니다. 지침 문서의 F44 항목이 정확히 "tabindex 로 의미와 조작성을 보존하지 못하는 탭 순서를 만드는 것" 을 가리켜요.

브라우저가 순서를 매기는 방식

왜 두 곳만 고쳤는데 전체가 밀렸을까요. HTML 표준이 정의한 순차 포커스 순서는 값에 따라 두 무리로 갈라지거든요.

먼저 양수 tabindex 를 가진 요소들이 값이 작은 순으로 한 무리를 이룹니다. 14 보다 먼저 오고, 같은 값끼리는 문서에 등장한 순서를 따라요. 그리고 그 무리가 전부 끝난 다음에야 tabindex="0" 과 속성을 안 쓴 요소들이 문서 순서대로 이어집니다. 그러니까 양수를 하나라도 심는 순간, 그 요소는 페이지의 다른 모든 것을 제치고 앞줄로 갑니다.

속성을 아예 안 썼을 때의 기본값도 알아두면 편해요. a, button, input, select, textarea, summary 같은 대화형 요소는 0 과 같게 동작하고, divspan 같은 나머지는 -1 과 같게 동작합니다. 대부분의 페이지가 tabindex 를 한 글자도 안 쓰고도 멀쩡히 탐색되는 건 이 기본값 덕분이에요.

"Developers should use caution when using values other than 0 or -1 for their tabindex attributes as this is complicated to do correctly." - HTML Standard

0-1 말고 다른 값을 쓸 거라면 각오하라는 말이에요. 표준 문서가 이런 톤으로 경고하는 속성 값은 흔치 않죠.

MDN 도 같은 자리에서 0-1 만 쓰기를 권합니다. 유효 범위가 1 부터 32767 까지라고 적혀 있긴 한데, 그 숫자를 실제로 써야 할 상황은 거의 오지 않아요. 시각적 순서와 탭 순서가 어긋난다면 tabindex 로 덮는 대신 DOM 순서 자체를 시각 순서에 맞추는 게 정공법입니다.

-1 은 포커스를 끄지 않아요

가장 많이 오해받는 값이 -1 이에요. "이 요소는 포커스 안 되게 하려고" 라는 말과 함께 붙는 경우가 많은데, 실제로 하는 일은 그게 아닙니다. -1 은 Tab 키로 도는 순차 탐색에서만 빠질 뿐, element.focus() 호출과 마우스 클릭으로는 그대로 포커스를 받아요.

이 성질이 쓸모 있는 자리가 있어요. 모달이 열릴 때 대화상자 자체로 포커스를 보내거나, 폼 검증에 실패했을 때 에러 메시지 영역으로 포커스를 옮기는 경우죠. 두 경우 모두 Tab 순서에 끼어들 필요는 없지만 스크립트로는 포커스를 받아야 하니 -1 이 정확히 맞는 값이에요. 모달 자체를 브라우저에 맡기는 방법은 지난 글에서 다뤘고, 여기서 짚을 건 <dialog> 요소에는 tabindex 를 붙이면 안 된다는 점입니다. 브라우저가 이미 포커스를 관리하거든요.

그럼 진짜로 포커스를 끄고 싶으면요. 표준이 정해둔 방법은 disabledinert 두 가지뿐이고, tabindex 로는 어떤 값을 넣어도 요소를 포커스 불가능하게 만들 수 없어요.

자식이 여러 개인 위젯의 탭 한 칸

버튼 여섯 개짜리 툴바를 만들었다고 해볼게요. 전부 기본값이면 탭 스톱이 여섯 개가 생깁니다. 툴바를 지나가려고 Tab 을 여섯 번 눌러야 하는 거예요. 툴바 하나가 그 정도면 페이지에 위젯이 몇 개만 더 있어도 키보드 사용자는 지쳐요.

여기서 쓰는 패턴이 roving tabindex 입니다. 이름 그대로 0 한 자리를 자식들 사이에서 옮겨 다니게 하는 방식이에요. ARIA Authoring Practices Guide 가 알고리즘을 세 단계로 정리해뒀어요.

1

진입 지점 하나만 0 으로

위젯 안에서 처음 포커스를 받을 자식 하나에만 tabindex 0 을 주고, 나머지 자식은 전부 -1 로 둡니다. 이러면 Tab 한 번에 위젯으로 들어오고, 한 번 더 누르면 위젯 밖으로 나가요.

2

화살표 키로 0 을 옮기기

방향키 입력이 오면 지금 자리의 값을 -1 로 되돌리고 새 자리를 0 으로 바꾼 뒤, 그 요소에 focus() 를 직접 호출합니다. 속성만 바꾸면 포커스는 그대로 있어요.

3

나갔다 돌아와도 그 자리

0 을 가진 자식이 항상 하나 남아 있으니, 포커스가 위젯 밖으로 나갔다가 Tab 으로 다시 들어와도 마지막에 쓰던 자리로 복귀합니다.

마크업만 떼어보면 이렇게 생겼어요.

<div role="toolbar">
  <button tabindex="0">굵게</button>
  <button tabindex="-1">기울임</button>
  <button tabindex="-1">밑줄</button>
</div>

아래 데모에서 Tab 으로 툴바에 들어온 뒤 좌우 화살표를 눌러보세요. 그 다음 Tab 을 한 번 더 누르면 툴바 전체를 한 칸에 빠져나갑니다.

비슷한 목적의 대안으로 aria-activedescendant 가 있어요. 컨테이너에만 tabindex="0" 을 주고 DOM 포커스는 컨테이너에 둔 채, 속성 값으로 "지금 활성인 자식" 만 보조 기술에 알리는 방식이에요. 다만 roving tabindex 에는 브라우저가 새로 포커스된 요소를 알아서 화면 안으로 스크롤해준다는 장점이 있어서, 목록이 길어지는 위젯에서는 이쪽이 편합니다.

자주 밟는 지점들

저도 그랬지만 divtabindex="0" 만 붙이고 버튼을 만들었다고 생각하기 쉬워요. 포커스는 잡히니까 다 된 것처럼 보이거든요. 그런데 보조 기술 입장에서 그 요소는 여전히 아무 역할도 없는 상자예요. 접근성 트리에 대화형 컴포넌트로 올라가려면 role 이 함께 있어야 하고, Enter 와 Space 처리도 직접 해야 합니다. 그래서 웬만하면 <button> 을 쓰는 게 빠르고, 이 판단은 버튼의 type 을 다룬 글의 결론과 같은 맥락이에요.

포커스를 옮길 때 이벤트를 쏘는 것도 흔한 실수예요.

"Do not dispatch the focus event to send focus to an element." - MDN

DOM 의 focus 이벤트는 "포커스가 옮겨졌다" 는 사후 통보지 명령이 아니에요. 실제로 옮기려면 element.focus() 를 호출해야 합니다.

포커스 링을 지우는 습관도 짚고 갈게요. outline: none 을 전역으로 깔아두면 키보드 사용자는 자기가 어디 있는지 알 수 없게 됩니다. 마우스 클릭 때의 링만 거슬리는 거라면 :focus-visible 로 조건을 좁히면 돼요.

탭 순서를 다 짠 뒤 확인할 것들

☐ 양수 tabindex 가 하나도 없는가
☐ Tab 만으로 페이지의 모든 조작을 끝낼 수 있는가
☐ 포커스가 지금 어디 있는지 눈으로 보이는가
☐ 위젯 하나를 지나는 데 탭 한 번이면 충분한가
div 로 만든 조작 요소에 role 과 키 핸들러가 붙어 있는가

정리하면 tabindex 는 값이 세 종류지만 실제로 고를 값은 두 개예요. 탭 순서에 넣고 싶으면 0, 스크립트로만 포커스를 보내고 싶으면 -1. 양수가 손에 잡히는 순간은 대개 DOM 순서가 시각 순서와 어긋났다는 신호고, 그때는 tabindex 가 아니라 마크업을 고치는 게 맞아요. 포커스를 옮기지 않고 변화만 알리고 싶을 때는 aria-live 를 다룬 글이 이어지는 이야기예요.

자주 묻는 질문

답을 펼치기 전에 스스로 답해보세요

안 붙여도 됩니다. a, button, input, select, textarea, summary 같은 대화형 요소는 기본값이 이미 0 과 같게 동작해요. 오히려 붙이면 나중에 값을 잘못 바꿨을 때 순서가 흔들릴 여지만 생깁니다.
포커스는 받지만 버튼이 되지는 않아요. 보조 기술이 대화형 컴포넌트로 인식하려면 role 이 필요하고, Enter 와 Space 키 처리도 직접 붙여야 합니다. 같은 결과를 button 요소는 한 줄로 주니까 특별한 이유가 없으면 button 을 쓰세요.

참고 자료

관련 글