개요
이번 글에선 웹접근성 관련 내용을 공유드리고자 합니다.
사실 웹 접근성이 중요한 이유는 조금만 검색해봐도 쉽게 찾을 수 있고, AI에게 물어봐도 충분히 잘 설명해줍니다.
하지만 개인적으로는 “그래서 웹 접근성은 어떻게 지키는 건데?” 라는 의문이 들 때가 많았습니다. 웹 접근성이 중요하다는 이야기는 많이 접하지만, 실제 개발 과정에서 구체적으로 무엇을 어떻게 해야 하는지는 막연하게 느껴지더라구요.
특히 웹 접근성은 정해진 하나의 공식이 있는 것이 아니라, UI 구조나 사용자 인터랙션, 사용되는 컴포넌트에 따라 고려해야 할 요소가 달라지는 경우가 많습니다. 그래서 개념을 이해하는 것과 실제 코드에 적용하는 것 사이에는 생각보다 큰 간극이 존재한다고 느꼈습니다.
때문에 이번 글에서는 저와 비슷한 고민을 하고 계신 분들에게 조금이나마 도움이 되었음 하는 바람과 함께 웹 접근성을 조금 더 꼼꼼하게 반영했던 사례를 공유드려보고자 합니다. UI에 어떤 접근성 원칙을 적용했고 이를 코드로 어떻게 구현했는지 정리해보았습니다.
물론 이 글에서 소개하는 코드가 정답아닐 수도 있습니다. 다만 웹 접근성을 준수하기 위해 어떤 고민과 시도를 했는지를 공유하는 정도로 가볍게 봐주시면 감사하겠습니다.
Case1. 탭 메뉴 컴포넌트
아래와 같은 탭 메뉴 UI가 있습니다.

이러한 UI에서 어떻게 웹접근성을 준수했는지 알아봅시다.
1. role=“tablist” 로 탭 컨테이너의 역할 정의
버튼리스트를 감싸는 div 태그에 role="tablist" 역할을 부여했는데요.
<div className={cn(LIST_TAB_CLASS)} role="tablist">
{tabList.map((item, index) => {
<Button
//..
>
{item}
</Button>시각적으로는 단순한 버튼 묶음이지만, 스크린리더에게는 이 안에 탭들이 있다는 의미를 전달해야 합니다.
이때 role="tablist"를 지정하면, 스크린 리더기 입장에서는 이 영역이 탭 컨트롤들의 리스트라는 것을 인식하게 됩니다.
2. 각 버튼에 role=“tab” 부여
<Button
//...
id={`${id}Tab${index}`}
role="tab"
>
해당 UI는 각 버튼을 클릭하면 해당되는 작품리스트가 노출되는 형식입니다.
즉, 이 UI에서는 버튼들이 서로 상호 배타적인 탭 역할을 합니다.
그래서 각 버튼에 role="tab"을 부여해, 단순한 버튼이 아니라 탭 컴포넌트의 한 항목이라는 정보를 스크린 리더기에 명확히 전달하도록 했습니다.
이렇게 구현해 두면 스크린리더는 “탭 1/3, 웹툰, 선택됨”과 같이 읽어줍니다.
3. role=“tabpanel”과 aria-labelledby로 패널과 탭 연결
const panelId = `${id}Panel`
const selectedTabId = selectedTabIndex >= 0 ? `${id}Tab${selectedTabIndex}` : undefined
//...
<div id={panelId} role="tabpanel" aria-labelledby={selectedTabId} >
{panel}
</div>콘텐츠 영역은 role="tabpanel"로 선언해, 탭에 의해 제어되는 패널임을 명시합니다.
aria-labelledby={selectedTabId}로 현재 선택된 탭의 id를 참조함으로써, 스크린리더는 이 패널을 “(선택된 탭의 레이블)에 대응하는 패널”로 이해합니다.
4. aria-controls로 탭 → 패널 방향 링크
<Button
//...
aria-controls={panelId}
>
aria-controls={panelId}반대로 탭에서는 aria-controls를 통해 자신이 제어하는 패널의 id를 알려줍니다.
이로써 탭과 패널은 양방향으로 연결됩니다.
- 탭: 나는 이 패널을 제어해
- 패널: 나는 이 탭에 의해 라벨링되어 있어
위 패턴 덕분에, 시각 정보 없이도 어떤 탭을 선택하면, 어떤 콘텐츠가 바뀌는지를 스크린 리더기가 안정적으로 추론할 수 있습니다.
5. 일관된 시각적·비시각적 정보 전달
탭 UI에서 중요한 것은 어떤 탭이 선택되어 있는지를 모든 사용자에게 일관되게 알려주는 것입니다.
const [selectedTab, setSelectedTab] = useState<ContentType>('웹툰')
//..
const selected = item === selectedTab
<Button
variant={selected ? 'solid-round' : 'outline-round'}
color={selected ? 'sub-green-01' : 'bright-01'}
...
aria-selected={selected}
>
{item}
</Button>위 코드를 통해 시각적·비시각적 접근성을 함께 만족시킬 수 있습니다.
-
시각적
selected값에 따라 활성 탭과 비활성 탭의 색상·배경이 달라지도록 설계했습니다.- 사용자는 한눈에 “지금 어떤 탭이 활성화되어 있는지”를 시각적으로 바로 파악할 수 있습니다.
-
비시각적 (스크린리더)
aria-selected={selected}로 동일한 상태 정보를 스크린리더에도 전달합니다.- 시각적으로 강조된 탭과 스크린리더가 “선택됨”이라고 읽어주는 탭이 일치해, 스크린 리더기 사용자에게도 정확한 탭 활성 상태를 전달할 수 있습니다.
Case2. 사이드바 웹 접근성
대부분의 웹사이트에서 흔하게 사용하는 UX 요소인 사이드바 역시 웹접근성을 고려해야 합니다.
사이드바 구현을 위해 shadcn에서 제공하는 Drawer 컴포넌트를 도입했는데요.
그 과정에서 아래와 같은 웹접근성 에러를 발견했습니다.
사이드바 웹접근성 에러

Blocked aria-hidden on an element because its descendant retained focus. The focus must not be hidden from assistive technology users. Avoid using aria-hidden on a focused element or its ancestor. Consider using the inert attribute instead, which will also prevent focus. For more details, see the aria-hidden section of the WAI-ARIA specification at https://w3c.github.io/aria/#aria-hidden.
Element with focus: <button.relative flex size-[25px] items-center justify-center bg-transparent>
Ancestor with aria-hidden: <div.Layout_wrap-layout__P4KWh> <div class="Layout_wrap-layout__P4KWh" style="--padding-bottom: 0; --margin-bottom: 40px;" data-aria-hidden="true" aria-hidden="true">…</div>사이드바로 포커스가 이동하지 않은 것이 원인
해당 에러 메시지를 풀어서 보면, 포커스를 가진 요소의 상위 DOM에 aria-hidden="true"가 걸려 있는 상태라는 뜻입니다.
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import { cn } from '@/src/common/utils/twMerge'
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
)shadcn Drawer 컴포넌트는 vaul이라는 UI 라이브러리를 기반으로 만들어졌는데요. 사이드바가 열릴 때 배경 콘텐츠를 숨기기 위한 aria-hidden 처리가 vaul 내부 로직에 포함되어 있습니다. 이를 통해 사이드바가 열린 동안에는 사용자가 배경 영역에 접근하지 못하도록 막아 주는 방식입니다.
aria-hidden이 적용된 영역은 스크린리더 등 보조기기에서 보이지 않는 영역으로 간주됩니다. 그 안에 포커스가 남아 있으면 사용자는 어디에 포커스가 있는지 인지할 수 없고, 조작도 어려운 상태가 됩니다.
즉, 사이드바 토글 버튼(button.relative ...) 클릭 시 사이드바가 열리면서 해당 버튼을 감싸고 있는 래퍼(Layout_wrap-layout__P4KWh)는 aria-hidden="true"로 처리되었지만,
포커스는 여전히 토글 버튼에 남아있었기 떄문에 브라우저가 이를 웹접근성 위반 가능성이 높은 상태로 판단하고 경고를 출력한 것입니다.
해결 (autofocus)
위 이슈를 해결하기 위해 코드를 아래와 같이 수정했습니다.
export default function SidebarProvider({ children }: { children: React.ReactNode }) {
return (
<Drawer direction="right" autoFocus>
{children}
</Drawer>
)
}
autoFocus 옵션을 통해 사이드바가 활성화되면 첫 포커스가 Drawer 내부로 이동하도록 해 더 이상 aria-hidden="true"가 걸린 영역 안에 포커스가 남아 있지 않도록 했습니다.
결과적으로 사이드바가 열릴 때 포커스가 Drawer 내부로 이동하도록 변경하여, 스크린리더 사용자도 사이드바 콘텐츠에 온전히 접근할 수 있도록 개선했습니다.
마무리
제 나름대로 UI에 맞게 웹접근성을 지키기 위해 여러 가지 시도를 해봤는데요.
앞으로도 단순히 UI를 그리는 데서 그치지 않고, 다양한 사용자가 편리하게 이용할 수 있는 웹사이트를 만드는 프론트엔드 개발자가 되기 위해 계속해서 고민하고 노력해야겠습니다.