본문 바로가기

UI|UX 구현

탭 컴포넌트 접근성 완벽 가이드 - WAI-ARIA 패턴으로 구현하는 법

{ } UI/UX 구현 탭 컴포넌트 접근성 있게 구현하기 Tab 키보드 네비게이션

탭 컴포넌트 접근성 완벽 가이드 - WAI-ARIA 패턴으로 구현하는 법

탭 컴포넌트는 제한된 공간에서 많은 정보를 효율적으로 제공하는 가장 대중적인 UI 패턴이지만, 접근성을 제대로 구현하지 않으면 키보드 사용자나 스크린 리더 사용자에게는 사용이 불가능한 컴포넌트가 되어버립니다. 이 글에서는 WAI-ARIA 표준을 준수하면서 실무에서 바로 적용할 수 있는 접근성 높은 탭 컴포넌트 구현 방법을 단계별로 알려드릴게요. 키보드 네비게이션, 포커스 관리, ARIA 속성 활용부터 실제 프로덕션 레벨의 코드까지 모두 다룰 예정입니다.

WAI-ARIA 탭 패턴의 핵심 원칙

WAI-ARIA(Web Accessibility Initiative - Accessible Rich Internet Applications)는 W3C에서 제정한 웹 접근성 표준이에요. 탭 컴포넌트에 대한 공식적인 디자인 패턴이 명시되어 있으며, 이를 준수하면 보조 기술 사용자도 문제없이 탭을 사용할 수 있습니다.

접근성 있는 탭 컴포넌트의 핵심은 세 가지예요:

1. 명확한 역할과 관계 정의: role="tablist", role="tab", role="tabpanel"을 통해 컴포넌트의 구조를 명확히 전달해야 합니다. 스크린 리더는 이 정보를 읽어서 "3개 중 1번째 탭"과 같은 맥락 정보를 제공하죠.

2. 키보드 네비게이션 지원: 마우스 없이도 탭 간 이동(←→ 화살표 키), 탭 활성화(Enter/Space), 처음/마지막 탭 이동(Home/End)이 가능해야 합니다.

3. 상태 관리와 포커스 제어: 현재 활성화된 탭을 aria-selected로 표시하고, 탭과 패널의 연결 관계를 aria-controlsaria-labelledby로 명시해야 해요.

이 세 가지 원칙을 지키지 않으면 시각적으로는 멋진 탭이지만, 접근성 측면에서는 완전히 망가진 UI가 됩니다.

HTML 마크업 구조와 ARIA 속성

접근성 있는 탭의 기본 마크업 구조를 살펴볼게요. 의미론적 HTML과 ARIA 속성을 적절히 조합하는 것이 핵심입니다.

<!-- 탭 리스트 컨테이너 -->
<div class="tabs">
  <!-- 탭 버튼들을 감싸는 리스트 -->
  <div role="tablist" aria-label="계정 설정">
    <!-- 첫 번째 탭 (활성화 상태) -->
    <button 
      role="tab" 
      aria-selected="true" 
      aria-controls="profile-panel"
      id="profile-tab"
      tabindex="0">
      프로필
    </button>

    <!-- 두 번째 탭 (비활성화 상태) -->
    <button 
      role="tab" 
      aria-selected="false" 
      aria-controls="security-panel"
      id="security-tab"
      tabindex="-1">
      보안
    </button>

    <!-- 세 번째 탭 (비활성화 상태) -->
    <button 
      role="tab" 
      aria-selected="false" 
      aria-controls="notification-panel"
      id="notification-tab"
      tabindex="-1">
      알림
    </button>
  </div>

  <!-- 첫 번째 탭 패널 (활성화) -->
  <div 
    role="tabpanel" 
    id="profile-panel"
    aria-labelledby="profile-tab"
    tabindex="0">
    <h3>프로필 설정</h3>
    <p>사용자 프로필 정보를 수정할 수 있습니다.</p>
  </div>

  <!-- 두 번째 탭 패널 (숨김) -->
  <div 
    role="tabpanel" 
    id="security-panel"
    aria-labelledby="security-tab"
    hidden>
    <h3>보안 설정</h3>
    <p>비밀번호 및 2단계 인증을 관리합니다.</p>
  </div>

  <!-- 세 번째 탭 패널 (숨김) -->
  <div 
    role="tabpanel" 
    id="notification-panel"
    aria-labelledby="notification-tab"
    hidden>
    <h3>알림 설정</h3>
    <p>이메일 및 푸시 알림을 설정합니다.</p>
  </div>
</div>

왜 이렇게 마크업하나요?

  • role="tablist": 스크린 리더에게 이것이 탭 그룹임을 알려줍니다. aria-label로 탭 그룹의 목적을 설명해주면 더 좋아요.
  • role="tab": 각 버튼이 탭 역할을 한다는 것을 명시합니다. <button> 요소를 사용하면 기본적으로 키보드 포커스가 가능하고 클릭 가능하죠.
  • aria-selected: 현재 활성화된 탭은 true, 나머지는 false로 설정합니다.
  • aria-controls: 탭이 어떤 패널을 제어하는지 ID로 연결합니다.
  • tabindex="0" vs tabindex="-1": 활성화된 탭만 0으로 설정해서 Tab 키로 포커스 받을 수 있게 하고, 나머지는 -1로 설정해서 화살표 키로만 이동 가능하게 합니다.
  • role="tabpanel" + aria-labelledby: 패널이 어떤 탭과 연결되어 있는지 명확히 합니다.
  • hidden 속성: 비활성 패널은 hidden으로 숨깁니다. display: none보다 의미론적으로 더 명확해요.

키보드 네비게이션 구현하기

탭 컴포넌트의 접근성에서 가장 중요한 부분이 키보드 네비게이션이에요. WAI-ARIA 권장사항에 따르면 다음 키보드 인터랙션을 지원해야 합니다.

class AccessibleTabs {
  constructor(tablistEl) {
    this.tablist = tablistEl;
    this.tabs = Array.from(tablistEl.querySelectorAll('[role="tab"]'));
    this.panels = Array.from(document.querySelectorAll('[role="tabpanel"]'));
    this.currentIndex = 0;

    this.init();
  }

  init() {
    // 각 탭에 이벤트 리스너 등록
    this.tabs.forEach((tab, index) => {
      // 클릭 이벤트
      tab.addEventListener('click', () => this.selectTab(index));

      // 키보드 이벤트
      tab.addEventListener('keydown', (e) => this.handleKeydown(e, index));
    });
  }

  handleKeydown(event, currentIndex) {
    const { key } = event;
    let newIndex = currentIndex;

    // 화살표 키로 탭 간 이동
    if (key === 'ArrowRight') {
      event.preventDefault(); // 페이지 스크롤 방지
      newIndex = (currentIndex + 1) % this.tabs.length; // 순환 이동
    } else if (key === 'ArrowLeft') {
      event.preventDefault();
      newIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length;
    } 
    // Home/End 키로 처음/마지막 탭 이동
    else if (key === 'Home') {
      event.preventDefault();
      newIndex = 0;
    } else if (key === 'End') {
      event.preventDefault();
      newIndex = this.tabs.length - 1;
    }
    // Enter나 Space로 탭 활성화 (이미 포커스된 상태에서)
    else if (key === 'Enter' || key === ' ') {
      event.preventDefault();
      this.selectTab(currentIndex);
      return;
    } else {
      return; // 다른 키는 무시
    }

    // 새 탭으로 포커스 이동
    this.tabs[newIndex].focus();
  }

  selectTab(index) {
    // 모든 탭 비활성화
    this.tabs.forEach((tab, i) => {
      const isSelected = i === index;

      // ARIA 상태 업데이트
      tab.setAttribute('aria-selected', isSelected);
      tab.setAttribute('tabindex', isSelected ? '0' : '-1');

      // 시각적 스타일링을 위한 클래스
      tab.classList.toggle('active', isSelected);
    });

    // 모든 패널 숨기기
    this.panels.forEach((panel, i) => {
      const isVisible = i === index;

      // hidden 속성으로 표시/숨김 제어
      if (isVisible) {
        panel.removeAttribute('hidden');
      } else {
        panel.setAttribute('hidden', '');
      }
    });

    this.currentIndex = index;
  }
}

// 사용 예시
const tablist = document.querySelector('[role="tablist"]');
new AccessibleTabs(tablist);

왜 이렇게 구현하나요?

  • 화살표 키 순환: 마지막 탭에서 오른쪽 화살표를 누르면 첫 번째 탭으로 돌아갑니다. 이는 WAI-ARIA 권장사항이에요.
  • event.preventDefault(): 화살표 키의 기본 동작(페이지 스크롤)을 막아 탭 내에서만 동작하게 합니다.
  • 포커스 vs 선택 분리: 화살표 키는 포커스만 이동하고, Enter/Space로 실제 탭을 활성화합니다. 이는 "수동 활성화(Manual Activation)" 패턴으로, 사용자가 탭을 탐색할 때 패널이 바로 바뀌지 않아 스크린 리더 사용자에게 더 편리해요.
  • tabindex 동적 관리: 활성화된 탭만 tabindex="0"으로 설정해서 Tab 키로 탭 그룹에 진입할 때 항상 활성화된 탭으로 포커스가 가게 합니다.

React로 접근성 탭 컴포넌트 만들기

실무에서는 React나 Vue 같은 프레임워크를 사용하는 경우가 많죠. React로 재사용 가능한 접근성 탭 컴포넌트를 만들어볼게요.

import { useState, useRef, useEffect } from 'react';

function AccessibleTabs({ tabs, defaultIndex = 0, label }) {
  const [selectedIndex, setSelectedIndex] = useState(defaultIndex);
  const tabsRef = useRef([]);

  // 탭 클릭 핸들러
  const handleTabClick = (index) => {
    setSelectedIndex(index);
  };

  // 키보드 네비게이션 핸들러
  const handleKeyDown = (event, currentIndex) => {
    const { key } = event;
    let newIndex = currentIndex;

    switch (key) {
      case 'ArrowRight':
        event.preventDefault();
        newIndex = (currentIndex + 1) % tabs.length;
        break;
      case 'ArrowLeft':
        event.preventDefault();
        newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
        break;
      case 'Home':
        event.preventDefault();
        newIndex = 0;
        break;
      case 'End':
        event.preventDefault();
        newIndex = tabs.length - 1;
        break;
      case 'Enter':
      case ' ':
        event.preventDefault();
        setSelectedIndex(currentIndex);
        return;
      default:
        return;
    }

    // 새 탭으로 포커스 이동
    tabsRef.current[newIndex]?.focus();
  };

  return (
    <div className="tabs">
      {/* 탭 리스트 */}
      <div 
        role="tablist" 
        aria-label={label}
        className="tabs__list">
        {tabs.map((tab, index) => {
          const isSelected = index === selectedIndex;
          const tabId = `tab-${index}`;
          const panelId = `panel-${index}`;

          return (
            <button
              key={tabId}
              ref={(el) => (tabsRef.current[index] = el)}
              role="tab"
              id={tabId}
              aria-selected={isSelected}
              aria-controls={panelId}
              tabIndex={isSelected ? 0 : -1}
              onClick={() => handleTabClick(index)}
              onKeyDown={(e) => handleKeyDown(e, index)}
              className={`tabs__tab ${isSelected ? 'tabs__tab--active' : ''}`}>
              {tab.label}
            </button>
          );
        })}
      </div>

      {/* 탭 패널들 */}
      {tabs.map((tab, index) => {
        const isSelected = index === selectedIndex;
        const tabId = `tab-${index}`;
        const panelId = `panel-${index}`;

        return (
          <div
            key={panelId}
            role="tabpanel"
            id={panelId}
            aria-labelledby={tabId}
            hidden={!isSelected}
            tabIndex={0}
            className="tabs__panel">
            {tab.content}
          </div>
        );
      })}
    </div>
  );
}

// 사용 예시
function App() {
  const accountTabs = [
    {
      label: '프로필',
      content: (
        <div>
          <h3>프로필 설정</h3>
          <form>
            <label>
              이름: <input type="text" defaultValue="홍길동" />
            </label>
          </form>
        </div>
      )
    },
    {
      label: '보안',
      content: (
        <div>
          <h3>보안 설정</h3>
          <button>비밀번호 변경</button>
        </div>
      )
    },
    {
      label: '알림',
      content: (
        <div>
          <h3>알림 설정</h3>
          <label>
            <input type="checkbox" /> 이메일 알림 받기
          </label>
        </div>
      )
    }
  ];

  return (
    <AccessibleTabs 
      tabs={accountTabs} 
      label="계정 설정"
      defaultIndex={0} 
    />
  );
}

export default App;

이 구현의 장점:

  • 재사용성: tabs prop으로 콘텐츠를 전달하면 어디서든 사용 가능합니다.
  • Ref 관리: useRef 배열로 각 탭 버튼에 대한 참조를 유지해서 프로그래밍 방식으로 포커스를 제어할 수 있어요.
  • 상태 관리: useState로 현재 선택된 탭 인덱스를 관리하고, 이를 기반으로 ARIA 속성을 동적으로 설정합니다.
  • 유연한 콘텐츠: tab.content에 JSX를 전달할 수 있어서 복잡한 UI도 탭 패널로 사용 가능합니다.

흔한 실수와 올바른 구현 방법

접근성 탭 컴포넌트를 구현할 때 자주 발생하는 실수들을 살펴볼게요.

실수 1: <a> 태그로 탭 구현하기

잘못된 예시:

<!-- ❌ 잘못된 방법 -->
<div role="tablist">
  <a href="#profile" role="tab" aria-selected="true">프로필</a>
  <a href="#security" role="tab">보안</a>
</div>

문제점: <a> 태그는 페이지 이동을 위한 요소예요. 탭은 페이지를 이동시키지 않고 현재 페이지 내에서 콘텐츠만 전환하므로 의미론적으로 맞지 않습니다. 또한 스크린 리더가 "링크"로 읽어서 사용자를 혼란스럽게 할 수 있어요.

올바른 예시:

<!-- ✅ 올바른 방법 -->
<div role="tablist">
  <button role="tab" aria-selected="true" aria-controls="profile-panel">
    프로필
  </button>
  <button role="tab" aria-controls="security-panel">
    보안
  </button>
</div>

<button> 요소는 상호작용을 위한 요소이므로 탭에 적합하고, 스크린 리더도 "버튼"으로 정확히 읽어줍니다.

실수 2: CSS만으로 패널 숨기기

잘못된 예시:

/* ❌ 잘못된 방법 */
.tab-panel {
  display: none;
}

.tab-panel.active {
  display: block;
}

문제점: display: none은 시각적으로만 숨기는 것이 아니라 스크린 리더에서도 완전히 제거되어 버립니다. 하지만 ARIA 속성을 제대로 활용하지 않으면 스크린 리더가 탭과 패널의 관계를 이해하지 못할 수 있어요.

올바른 예시:

<!-- ✅ 올바른 방법 -->
<div role="tabpanel" id="profile-panel" aria-labelledby="profile-tab">
  프로필 내용
</div>

<div role="tabpanel" id="security-panel" aria-labelledby="security-tab" hidden>
  보안 내용
</div>
/* hidden 속성은 기본적으로 display: none이지만, 의미론적으로 명확함 */
[role="tabpanel"][hidden] {
  display: none;
}

hidden 속성을 사용하면 HTML에서 숨김 상태를 명확히 표현하고, 접근성 트리에서도 올바르게 처리됩니다.

실수 3: 모든 탭에 tabindex="0" 설정

잘못된 예시:

<!-- ❌ 잘못된 방법 -->
<div role="tablist">
  <button role="tab" tabindex="0">탭 1</button>
  <button role="tab" tabindex="0">탭 2</button>
  <button role="tab" tabindex="0">탭 3</button>
</div>

문제점: Tab 키를 누를 때마다 모든 탭을 거쳐야 해서 키보드 사용자에게 매우 불편합니다. 탭이 10개라면 10번 Tab을 눌러야 다음 요소로 갈 수 있어요.

올바른 예시:

<!-- ✅ 올바른 방법 -->
<div role="tablist">
  <button role="tab" tabindex="0" aria-selected="true">탭 1</button>
  <button role="tab" tabindex="-1">탭 2</button>
  <button role="tab" tabindex="-1">탭 3</button>
</div>

활성화된 탭만 tabindex="0"으로 설정하고, 나머지는 -1로 설정합니다. 탭 간 이동은 화살표 키로만 하도록 구현해야 해요.

자동 활성화 vs 수동 활성화 패턴

탭 컴포넌트에는 두 가지 활성화 패턴이 있어요. 각각의 장단점을 이해하고 상황에 맞게 선택해야 합니다.

자동 활성화 (Automatic Activation)

화살표 키로 포커스를 이동하면 즉시 해당 탭이 활성화되는 패턴입니다.

// 자동 활성화 구현
handleKeyDown(event, currentIndex) {
  const { key } = event;
  let newIndex = currentIndex;

  if (key === 'ArrowRight') {
    event.preventDefault();
    newIndex = (currentIndex + 1) % this.tabs.length;
  } else if (key === 'ArrowLeft') {
    event.preventDefault();
    newIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length;
  }

  // 포커스 이동과 동시에 탭 활성화
  if (newIndex !== currentIndex) {
    this.selectTab(newIndex);
    this.tabs[newIndex].focus();
  }
}

장점: 빠른 탐색이 가능합니다. 한 번의 키 입력으로 탭이 바로 전환돼요.

단점: 스크린 리더 사용자가 탭을 탐색할 때 각 탭의 내용이 바로 읽혀서 혼란스러울 수 있습니다. 또한 무거운 콘텐츠가 있는 탭의 경우 성능 문제가 발생할 수 있어요.

적합한 상황: 탭이 2-3개로 적고, 각 패널의 콘텐츠가 가볍고, 탭 레이블만으로 내용을 충분히 알 수 있을 때

수동 활성화 (Manual Activation)

화살표 키로 포커스만 이동하고, Enter나 Space 키로 명시적으로 활성화하는 패턴입니다.

// 수동 활성화 구현 (위의 React 예시가 이 패턴)
handleKeyDown(event, currentIndex) {
  const { key } = event;
  let newIndex = currentIndex;

  if (key === 'ArrowRight') {
    event.preventDefault();
    newIndex = (currentIndex + 1) % this.tabs.length;
  } else if (key === 'ArrowLeft') {
    event.preventDefault();
    newIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length;
  } else if (key === 'Enter' || key === ' ') {
    event.preventDefault();
    this.selectTab(currentIndex); // 명시적 활성화
    return;
  }

  // 포커스만 이동
  if (newIndex !== currentIndex) {
    this.tabs[newIndex].focus();
  }
}

장점: 스크린 리더 사용자가 탭 레이블을 충분히 탐색한 후 원하는 탭을 선택할 수 있어요. 무거운 콘텐츠 로딩을 방지할 수 있습니다.

단점: 두 번의 키 입력(화살표 + Enter)이 필요해서 약간 번거로울 수 있습니다.

적합한 상황: 탭이 많거나, 각 패널이 무거운 콘텐츠를 로드하거나, 스크린 리더 사용자 경험이 중요한 경우

WAI-ARIA는 두 패턴 모두를 유효하다고 보지만, 일반적으로 수동 활성화 패턴을 더 권장합니다. 접근성 측면에서 더 안전하기 때문이에요.

스타일링과 포커스 인디케이터

접근성을 위해서는 시각적 피드백도 중요합니다. 특히 키보드 사용자를 위한 포커스 인디케이터는 필수예요.

/* 탭 기본 스타일 */
.tabs__tab {
  padding: 12px 24px;
  border: none;
  background: transparent;
  cursor: pointer;
  font-size: 16px;
  color: #666;
  border-bottom: 3px solid transparent;
  transition: all 0.2s ease;
}

/* 활성화된 탭 */
.tabs__tab[aria-selected="true"] {
  color: #0066cc;
  border-bottom-color: #0066cc;
  font-weight: 600;
}

/* 호버 상태 */
.tabs__tab:hover {
  background-color: #f5f5f5;
}

/* ✅ 포커스 인디케이터 (절대 제거하면 안 됨!) */
.tabs__tab:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

/* 포커스가 가시적일 때만 인디케이터 표시 (:focus-visible 활용) */
.tabs__tab:focus:not(:focus-visible) {
  outline: none;
}

.tabs__tab:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.1);
}

/* 탭 패널 스타일 */
.tabs__panel {
  padding: 24px;
  border: 1px solid #e0e0e0;
  border-top: none;
}

/* 탭 패널에 포커스가 갈 수 있으므로 포커스 스타일 제공 */
.tabs__panel:focus {
  outline: 2px dashed #0066cc;
  outline-offset: -4px;
}

스타일링 시 주의사항:

  1. outline: none 절대 금지: 포커스 인디케이터를 제거하면 키보드 사용자가 현재 어디에 포커스가 있는지 알 수 없어요. 대신 :focus-visible을 활용해 마우스 클릭 시에는 표시하지 않고 키보드 사용 시에만 표시할 수 있습니다.

  2. 충분한 색상 대비: WCAG 기준 최소 4.5:1의 명암비를 유지해야 합니다. 비활성 탭과 활성 탭의 색상 차이가 명확해야 해요.

  3. 여러 시각적 단서 제공: 색상만으로 상태를 구분하면 색맹 사용자가 구분하기 어려워요. 색상 + 밑줄 + 폰트 굵기 등 여러 방법을 조합하세요.

  4. 애니메이션 주의: prefers-reduced-motion 미디어 쿼리로 모션 민감도가 높은 사용자를 배려합니다.

/* 모션 민감도 고려 */
@media (prefers-reduced-motion: reduce) {
  .tabs__tab {
    transition: none;
  }
}

성능과 유지보수 팁

실무에서 사용할 때 고려해야 할 성능 및 유지보수 관점의 팁을 공유할게요.

1. 지연 로딩 (Lazy Loading)

탭 패널의 콘텐츠가 무거운 경우, 해당 탭이 처음 활성화될 때만 로드하도록 구현하세요.

function LazyTabPanel({ isActive, children, onFirstActivation }) {
  const [hasBeenActivated, setHasBeenActivated] = useState(false);

  useEffect(() => {
    if (isActive && !hasBeenActivated) {
      setHasBeenActivated(true);
      onFirstActivation?.();
    }
  }, [isActive, hasBeenActivated]);

  // 한 번도 활성화되지 않은 탭은 렌더링하지 않음
  if (!hasBeenActivated && !isActive) {
    return null;
  }

  return (
    <div role="tabpanel" hidden={!isActive}>
      {children}
    </div>
  );
}

2. URL 동기화

탭 상태를 URL과 동기화하면 페이지 공유나 브라우저 뒤로가기가 자연스럽게 동작해요.

function TabsWithRouter({ tabs }) {
  const [searchParams, setSearchParams] = useSearchParams();
  const activeTab = searchParams.get('tab') || '0';
  const selectedIndex = parseInt(activeTab, 10);

  const handleTabChange = (index) => {
    setSearchParams({ tab: index.toString() });
  };

  return <AccessibleTabs 
    tabs={tabs} 
    selectedIndex={selectedIndex}
    onTabChange={handleTabChange} 
  />;
}

3. 테스트 가능한 구조

접근성 속성을 활용해 테스트를 작성하면 유지보수가 쉬워져요.

// Jest + Testing Library 예시
test('키보드로 탭 간 이동이 가능해야 함', () => {
  render(<AccessibleTabs tabs={mockTabs} />);

  const firstTab = screen.getByRole('tab', { name: '프로필' });
  const secondTab = screen.getByRole('tab', { name: '보안' });

  // 첫 번째 탭에 포커스
  firstTab.focus();
  expect(firstTab).toHaveFocus();

  // 오른쪽 화살표 키 입력
  fireEvent.keyDown(firstTab, { key: 'ArrowRight' });

  // 두 번째 탭으로 포커스 이동 확인
  expect(secondTab).toHaveFocus();
});

test('스크린 리더가 탭과 패널의 관계를 이해할 수 있어야 함', () => {
  render(<AccessibleTabs tabs={mockTabs} />);

  const firstTab = screen.getByRole('tab', { name: '프로필' });
  const firstPanel = screen.getByRole('tabpanel', { name: '프로필' });

  expect(firstTab).toHaveAttribute('aria-controls', firstPanel.id);
  expect(firstPanel).toHaveAttribute('aria-labelledby', firstTab.id);
});

결론: 접근성은 선택이 아닌 필수

접근성 있는 탭 컴포넌트 구현의 핵심을 정리하면:

  1. 올바른 ARIA 역할과 속성 사용: role="tablist", role="tab", role="tabpanel"을 정확히 적용하고, aria-selected, aria-controls, aria-labelledby로 관계를 명시하세요.

  2. 완벽한 키보드 네비게이션: 화살표 키로 탭 간 이동, Home/End로 처음/마지막 이동, Enter/Space로 활성화가 가능해야 합니다.

  3. 적절한 tabindex 관리: 활성화된 탭만 tabindex="0", 나머지는 -1로 설정해서 Tab 키 네비게이션을 효율적으로 만드세요.

  4. 명확한 시각적 피드백: 포커스 인디케이터를 절대 제거하지 말고, 충분한 색상 대비를 유지하세요.

  5. 수동 활성화 패턴 우선 고려: 스크린 리더 사용자와 성능 측면에서 더 안전합니다.

실무 적용 팁: 접근성은 마지막에 추가하는 것이 아니라 처음부터 설계에 포함해야 합니다. 새 프로젝트를 시작할 때 위의 React 컴포넌트를 컴포넌트 라이브러리에 추가하고, 프로젝트 전체에서 재사용하세요. 한 번 제대로 만들어두면 모든 탭 컴포넌트가 자동으로 접근성을 갖추게 됩니다.

접근성은 전 세계 인구의 15%(약 10억 명)에 해당하는 장애인뿐 아니라, 일시적인 부상을 입은 사람, 노인, 모바일 환경의 사용자 모두에게 혜택을 줍니다. 더 많은 사람이 우리의 서비스를 사용할 수 있다는 것은 비즈니스 관점에서도 명백한 이점이에요.

관련해서 더 공부하면 좋은 주제들:

  • 모달 다이얼로그 접근성: 포커스 트랩, Esc 키 처리, 배경 스크롤 방지
  • 드롭다운 메뉴 접근성: Combobox 패턴, 자동완성 구현
  • ARIA Live Regions: 동적 콘텐츠 변경을 스크린 리더에 알리는 방법

접근성은 모든 개발자가 갖춰야 할 기본 역량입니다. 오늘부터 여러분의 컴포넌트에 접근성을 적용해보세요!