Skip to main content
ENKO

유틸리티

특정 컴포넌트 범주에 넣기 애매한 접근성, 레이아웃, 공용 패턴용 도우미.

여기 있는 것들은 평소 화면에는 아무것도 그리지 않는다. 그래서 오히려 구현 세부 사항으로 묻어 두지 않고 컴포넌트로 문서화한다. 누군가의 머릿속에만 있는 유틸리티는 만들 때마다 조금씩 다르게 구현되고, 잘못돼도 스크린 리더 사용자가 직접 겪기 전까지는 아무도 모른다.

컴포넌트

ComponentPurpose
Screenreader-only화면에서는 숨기고 스크린 리더는 읽을 수 있게 하는 콘텐츠
Skip navigation키보드 사용자가 반복되는 영역을 건너뛰게 한다
Focus trap모달이나 다이얼로그 안에 포커스를 가둔다
Visually hidden input네이티브 입력의 접근성은 살리고 체크박스/라디오 모양만 바꾼다
Live region바뀐 콘텐츠를 스크린 리더에 알린다

Screenreader-only

접근성 트리에는 남겨 두고 화면에서만 숨긴다. <VisuallyHidden> 컴포넌트로 구현한다.

  • 아이콘만 있는 버튼에 설명 라벨을 붙일 때 쓴다
  • 눈으로 보면 알 수 있는 맥락을 스크린 리더 사용자에게도 전할 때 쓴다
  • 읽혀야 하는 콘텐츠에는 display: none이나 visibility: hidden을 쓰지 않는다
  • asChild를 주면 <span>으로 감싸지 않고 sr-only 클래스를 자식 요소에 바로 붙인다

Props

PropTypeDefaultDescription
childrenReactNode(required)화면에서 숨길 콘텐츠
asChildbooleanfalse감싸지 않고 자식 요소에 클래스를 붙인다
classNamestring—추가 클래스

사용

  • 아이콘만 있는 버튼: 버튼 안 아이콘 옆에 <VisuallyHidden> 라벨을 넣는다
  • 데이터 테이블: 눈으로 봐도 뜻이 애매한 열 헤더에 스크린 리더용 설명을 덧붙인다
  • 내비게이션: 시각적 단서를 보완하는 설명 텍스트를 넣는다

숨기는 방식

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

요소는 크기가 0이 되지 않고 레이아웃에도 남는다. display: none, visibility: hidden, width: 0, aria-hidden은 모두 접근성 트리에서 요소를 빼 버리니, 원하는 결과와 정반대다. white-space: nowrap은 긴 숨김 문자열이 보이지 않는 좁은 열 안에서 줄바꿈되는 걸 막는다. 그렇게 되면 일부 브라우저는 한 줄에 한 단어씩 끊어 읽는다.

숨김 텍스트인가 aria-label인가

UseWhen
<VisuallyHidden>페이지의 다른 텍스트와 함께 번역돼야 하거나, 보이는 텍스트를 보완할 때
aria-label요소에 텍스트가 전혀 없고 짧은 이름 하나면 될 때

Senco에서는 숨김 텍스트를 우선 쓴다. 숨김 텍스트는 화면 카피와 같은 현지화 파이프라인을 타지만, aria-label 문자열은 번역할 때 자주 빠진다. 그러면 다 번역된 화면에 접근 가능한 이름만 영어로 남는다.

권장과 금지

DoDon't
Add hidden context to an ambiguous link ("Edit recipient Maria")Hide an entire duplicate version of the page for screen readers
Use it for an icon-only button's nameUse it to hide text you have decided is unimportant
Keep it shortWrite a paragraph only screen reader users receive

구조

레이아웃과 접근성 트리에 그대로 남아 있는, 1×1로 잘라 낸 요소.

변형

하나뿐이다. asChild로 감쌀지, 자식에 붙일지를 정한다.

API

interface VisuallyHiddenProps {
  children: React.ReactNode;
  asChild?: boolean;                // default: false — merge sr-only onto the child instead of wrapping
  className?: string;
}

상태

상태가 없다. 컴포넌트 자체는 보이지도 않고 포커스도 받지 않는다. 하지만 자식은 포커스를 받을 수 있고, 숨긴 컨테이너 안에 포커스 가능한 요소가 있으면 키보드 함정이 된다.

동작

자식을 접근성 트리에는 넣고, 화면 레이아웃에서는 뺀다. asChild를 주면 감싸는 요소를 추가하지 않고 클래스를 자식에 바로 붙인다.

콘텐츠

짧게 쓰고, 페이지의 다른 텍스트와 함께 번역되게 한다. 번역 때 자주 빠지는 aria-label보다 이 방식을 우선 쓰는 이유다.

접근성

display: none, visibility: hidden, 크기 0 대신 클리핑으로 숨긴다. 그 셋은 모두 콘텐츠를 접근성 트리에서 빼 버려서 의도와 정반대 결과가 난다.

키보드

포커스를 받지 않는다.

토큰

없다. 화면에 그리는 스타일이 없다.

Screenreader-only
Icon button with VisuallyHidden label
<VisuallyHidden>

Visible: Account balance (Current balance for your primary checking account)

Skip navigation

키보드로 포커스하면 나타나는 링크로, 매번 반복되는 내비게이션을 건너뛰게 해 준다.

  • 페이지에서 가장 먼저 포커스를 받는 요소다
  • 본문 랜드마크로 연결한다
  • :focus일 때만 보이고, 평소에는 숨겨 둔다
  • WCAG 2.4.1(블록 건너뛰기)을 통과해야 한다

요구사항

RequirementWhy
DOM에서 가장 먼저 포커스를 받는 요소내비게이션 뒤에 있으면 건너뛸 게 없다
대상에 tabindex="-1"없으면 포커스가 앵커에 머물러, 다음 Tab에서 다시 내비게이션으로 돌아간다
:focus일 때 보인다링크가 안 보이면, 눈으로 보며 키보드를 쓰는 사용자는 포커스가 어디 갔는지 몰라 길을 잃는다
대상은 <main> 랜드마크감싸는 div가 아니라 실제 콘텐츠로 가야 한다

테스트하는 법

페이지를 열고 Tab을 한 번 눌러 링크가 화면에 나타나는지 본다. Enter를 누르고 Tab을 한 번 더 눌러, 포커스가 내비게이션 맨 위로 돌아가지 않고 본문의 첫 컨트롤로 가는지 확인한다. 대개 뒷부분에서 실패한다.

구조

헤더보다 앞에 렌더링하는 앵커 하나.

변형

하나뿐이다.

API

interface SkipNavProps {
  targetId?: string;                // default: "ds-main-content"
  label?: string;                   // default: "Skip to main content"
}

대상 요소에는 tabIndex={-1}가 있어야 한다. 없으면 포커스가 앵커에 머물러 다음 Tab에서 다시 내비게이션으로 돌아간다. 이 컴포넌트가 막으려는 게 바로 그 문제다.

상태

StateRendering
Hidden잘라서 숨겼지만 DOM에 있고, 탭 순서 맨 앞
Focused화면에 보이고, 다른 모든 UI 위로 뷰포트 맨 위에 고정

동작

헤더보다 앞에 렌더링해서 첫 번째 탭 정지점이 되게 한다. 누르면 포커스가 대상으로 가고, 대상에는 tabIndex={-1}가 있어야 한다.

콘텐츠

"Skip to main content". 짧게 쓰는 것보다 어디로 가는지 밝히는 게 더 중요하다.

접근성

WCAG 2.4.1 Bypass Blocks를 충족한다. Tab, Enter, 다시 Tab 순서로 눌러 확인한다. 포커스가 내비게이션으로 돌아가지 않고 본문 안으로 가야 한다.

키보드

KeyAction
Tab (페이지에서 처음 누를 때)나타난다
Enter대상으로 포커스 이동

토큰

--ds-primary · --ds-on-accent · --ds-z-sticky · --ds-radius-md

Skip navigation link

Tab into the box below to reveal the skip link:

Focus trap

특정 영역(다이얼로그, 모달, 드로어) 안에 키보드 포커스를 가둔다.

  • Tab을 누르면 영역 안의 포커스 가능한 요소만 순환한다
  • Shift+Tab은 영역 안에서 거꾸로 이동한다
  • 가두기가 풀리면 포커스는 영역을 열었던 요소로 돌아간다
  • Dialog와 Popover 컴포넌트에는 @base-ui/react로 이미 들어 있다

생애 주기

PhaseBehavior
Open첫 번째 포커스 가능한 요소로, 없으면 다이얼로그 컨테이너로 포커스가 간다
While openTab이 안에서만 순환한다. 바깥 콘텐츠는 inert 또는 aria-hidden
Esc가두기를 풀고 닫는다
Close포커스가 영역을 열었던 요소로 돌아간다

가장 자주 빠뜨리는 게 포커스를 돌려주는 단계다. 이게 없으면 다이얼로그를 닫는 순간 포커스가 <body>로 떨어져, 키보드 사용자는 페이지 맨 위부터 다시 시작해야 한다. 그러면 대부분 내비게이션을 다시 Tab으로 훑어 내려가기보다 그냥 포기한다.

함정이 틀린 곳

포커스를 가두는 건 다이얼로그, 시트, 모달 팝오버 같은 모달 UI뿐이다. 비모달 팝오버, 드롭다운, 호버 카드에서는 가두면 안 된다. 포커스가 밖으로 나가면 닫히는 게 이들의 동작 방식이기 때문이다. 비모달 UI에서 포커스가 갇히면 사용자에게는 페이지가 고장 난 것과 다를 바 없다.

구조

양 끝에 센티널 노드를 둔 래퍼. 화면에는 아무것도 그리지 않는다.

변형

하나뿐이다. 비모달 UI에는 쓰지 않는다.

API

interface FocusTrapProps {
  active: boolean;
  children: React.ReactNode;
  initialFocus?: React.RefObject<HTMLElement>;  // default: first focusable descendant
  returnFocusTo?: React.RefObject<HTMLElement>; // default: the element focused when active became true
  onEscape?: () => void;
}

모달 UI에서만 포커스를 가둔다. 비모달 팝오버에서 포커스가 갇히면 고장 난 페이지와 다를 바 없다.

상태

StateBehaviour
Inactive아무 효과 없음. 자식은 평소대로 동작한다
ActiveTab이 안에서만 순환한다. 바깥 콘텐츠는 inert
Releasing컴포넌트가 언마운트되기 전에 포커스가 returnFocusTo로 돌아간다

동작

켜지면 포커스를 안으로 옮기고, 꺼지면 원래 자리로 돌려준다. 바깥 콘텐츠는 inert로 만들어 스크린 리더로 탐색할 수 없게 한다.

콘텐츠

없다. 자체 콘텐츠를 그리지 않는다.

접근성

모달 UI에는 필수, 비모달 UI에는 금지다. 팝오버에서 포커스가 갇히면 고장 난 페이지와 다를 바 없다.

키보드

KeyAction
Tab / Shift+Tab안에서 순환 이동
EsconEscape를 호출한다. 컴포넌트가 직접 닫지는 않는다

토큰

없다.

Visually hidden input

체크박스와 라디오를 직접 디자인할 때, 네이티브 <input>은 화면 밖으로 빼 두고 그대로 쓰는 방식.

  • 네이티브 키보드 동작과 폼 제출이 그대로 살아 있다
  • 스크린 리더는 화면에 그린 모양이 아니라 네이티브 입력을 읽는다
  • :checked 상태에 따라 CSS 형제 선택자로 모양을 바꾼다
  • 기본 Checkbox/Switch 컴포넌트가 디자인에 맞지 않을 때 쓴다

컨트롤을 다시 만들지 않는 이유

네이티브 <input type="checkbox">에는 체크 상태, 키보드 실행, 폼 참여, 올바른 역할, 라벨 연결, :indeterminate가 기본으로 들어 있다. role="checkbox"를 단 div는 이걸 하나하나 다시 구현해야 하는데, 대개 폼 참여를 빠뜨린다. 겉보기엔 멀쩡한데 제출하면 아무 값도 넘어가지 않는다.

요구사항

  • 입력은 DOM과 탭 순서에 남겨 둔다. 화면 밖으로 옮길 뿐, display: none은 쓰지 않는다.
  • 화면에 그린 대체 모양은 aria-hidden이고 포커스를 받지 않는다.
  • 실제 <label>로 입력을 감싸거나 연결해서, 라벨을 눌러도 체크되게 한다.
  • 포커스 링은 숨긴 입력의 :focus-visible을 형제 선택자로 받아 대체 모양에 그린다. 포커스가 눈에 안 보이는 화면 밖 입력은 사실상 키보드 함정이다.

구조

화면 밖의 네이티브 입력과, 입력 대신 포커스 링을 그려 주는 aria-hidden 표시 요소로 이뤄진다.

변형

입력 type에 따라 checkbox, radio, file이 있다.

API

interface VisuallyHiddenInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  type: "checkbox" | "radio" | "file";
  indicator: React.ReactNode;       // the visual replacement — rendered aria-hidden, non-focusable
}

입력은 화면 밖에 둔 채 DOM과 탭 순서에 남긴다. display: none을 쓰면 접근성 트리에서도, 폼 제출에서도 빠진다.

상태

네이티브 입력의 상태를 그대로 따른다. 표시 요소는 숨긴 입력의 :checked와 :focus-visible을 형제 선택자로 받아 모양을 바꾼다.

동작

실행, 폼 참여, 상태는 네이티브 입력이 처리한다. 표시 요소는 그 상태를 보여 줄 뿐이다.

콘텐츠

없다. 텍스트는 연결된 <label>에 담는다.

접근성

입력은 탭 순서와 접근성 트리에 남는다. 대체 모양은 aria-hidden이고 포커스를 받지 않는다. 대신 형제 선택자로 포커스 링을 거기에 그려서, 키보드 사용자도 포커스 위치를 볼 수 있다.

키보드

KeyAction
Tab숨긴 입력이 탭 정지점이 된다
Space토글

토큰

대체하는 컨트롤의 토큰을 그대로 쓴다.

Live region

aria-live로 콘텐츠가 바뀐 것을 스크린 리더에 알린다.

  • politeness: "polite"(기본값)는 사용자가 듣던 내용을 마칠 때까지 기다리고, "assertive"는 바로 끊고 읽는다
  • atomic: true(기본값)는 영역 전체를 읽고, false는 바뀐 노드만 읽는다
  • relevant: 어떤 변화가 생겼을 때 읽을지 정한다. "additions", "removals", "text", "all"
  • role: 시맨틱 역할 "status", "alert", "log", "timer". "alert"와 "status"는 aria-live가 포함돼 있으므로 따로 쓰지 않는다
  • visuallyHidden: true(기본값)면 sr-only를 적용한다. 라이브 콘텐츠가 화면에도 보여야 하면 false로 둔다

Props

PropTypeDefaultDescription
politeness"polite" | "assertive""polite"안내의 긴급도
atomicbooleantrue영역 전체를 읽을지, 바뀐 부분만 읽을지
relevant"additions" | "removals" | "text" | "all"—어떤 변화를 읽을지
role"status" | "alert" | "log" | "timer"—시맨틱 역할 (일부는 aria-live를 포함)
visuallyHiddenbooleantruesr-only 스타일 적용

사용

  • 송금 확인: 폼 제출 후 polite로 안내
  • 에러 경고: role="alert"로 assertive 안내
  • 채팅/로그 피드: role="log"에 relevant="additions"
  • 타이머: 카운트다운 표시에 role="timer"

우선순위 고르기

PolitenessUse forSenco examples
polite사용자가 한숨 돌릴 때 알려도 되는 상태"Rate updated", "3 recipients found", "Saved"
assertive사용자가 지금 당장 해야 할 일이 바뀌는 상황송금 실패, 세션 만료 임박, 잔액 부족

Assertive는 읽던 문장 중간에 끼어든다. 30초마다 갱신되는 환율을 assertive 영역에 넣으면, 사용자가 수수료 내역을 읽는 동안 네 번이나 말이 끊긴다. Senco에서 assertive는 실패와 보안 이벤트에만 쓴다.

컨테이너가 먼저 있어야 한다

라이브 영역은 이미 접근성 트리에 있던 영역이 바뀔 때만 읽힌다. {error && <LiveRegion>{error}</LiveRegion>}처럼 렌더링하면 영역과 내용이 같은 틱에 나타나서 아무것도 읽히지 않는다. 컨테이너는 항상 마운트해 두고, 안의 텍스트만 바꾼다.

송금 플로우의 안내

EventRegionPoliteness
환율 갱신Statuspolite
수취인 검색 결과 도착Statuspolite
blur할 때 필드 검증 실패필드의 aria-describedby— (라이브 영역 아님)
폼 제출 실패에러 요약assertive (role="alert")
송금 요청 완료Statuspolite
송금 실패Alertassertive

필드 수준 검증을 라이브 영역으로 만들지 않은 건 의도한 것이다. blur할 때마다 메시지를 읽어 주면 아직 폼을 채우는 사용자를 방해한다. 메시지는 aria-describedby로 필드에 연결해 두고, 사용자가 그 필드로 돌아왔을 때 읽힌다. Validation 참고.

구조

화면에서 숨긴 컨테이너. 이 영역으로 안내를 내보내는 UI가 떠 있는 동안 계속 마운트해 둔다.

변형

VariantDimensionRole
Statustyperole="status", polite
Alerttyperole="alert", assertive
Logtyperole="log", 추가된 내용만
Timertyperole="timer"

API

interface LiveRegionProps {
  children: React.ReactNode;
  politeness?: "polite" | "assertive";                        // default: "polite"
  atomic?: boolean;                                           // default: true
  relevant?: "additions" | "removals" | "text" | "all";
  role?: "status" | "alert" | "log" | "timer";
  visuallyHidden?: boolean;                                   // default: true
}

컴포넌트는 안내할 콘텐츠가 바뀌기 전에 이미 마운트돼 있어야 한다. 메시지와 함께 조건부로 렌더링하면 영역과 내용이 같은 틱에 접근성 트리에 들어가서 아무것도 읽히지 않는다.

상태

StateBehaviour
Mounted, empty반드시 거쳐야 하는 대기 상태. 영역이 내용보다 먼저 있어야 한다
Content changed설정한 politeness로 읽힌다
Unmounted아무것도 읽히지 않는다. 이 컴포넌트가 막으려는 버그가 바로 이것이다

동작

담당하는 UI가 떠 있는 동안 계속 마운트해 둔다. 바뀌는 건 안의 텍스트뿐이다. 메시지와 함께 조건부로 렌더링하면 아무것도 읽히지 않는다.

콘텐츠

무엇에 대한 안내인지 밝힌 한 문장. "Completed"가 아니라 "Transfer to Maria Santos is now completed"처럼 쓴다.

접근성

이 컴포넌트 자체가 접근성 장치다. role="status"와 role="alert"에는 각자의 politeness가 포함돼 있으므로, role을 주면 aria-live는 생략한다.

키보드

포커스를 받지 않는다.

토큰

없다.

Live region

Click the button to trigger a screen reader announcement:

politeness="polite"politeness="assertive"role="status"role="alert"role="log"role="timer"

디자인과 접근성

  • 스크린 리더 전용 콘텐츠는 의미 있게, 간결하게 쓴다
  • 실제 스크린 리더(VoiceOver, NVDA)로 어떻게 읽히는지 테스트한다
  • 건너뛰기 내비게이션은 가장 먼저 포커스를 받는 요소여야 한다
  • 포커스 트랩은 Escape로 풀 수 있어야 하고, 풀리면 포커스를 열었던 요소로 돌려준다
  • 대체 표시 없이 포커스 외곽선을 없애지 않는다
  • 라이브 영역은 polite를 기본으로 쓰고, assertive는 에러나 중요한 경고에만 쓴다
  • 라이브 영역은 콘텐츠가 바뀌기 전에 DOM에 있어야 한다. 컨테이너를 조건부로 렌더링하지 않는다
  • 안내는 짧게 쓴다. 새 polite 안내가 와도 스크린 리더는 읽던 문장을 끊지 않는다

이 유틸리티들은 눈으로 검증할 수 없다

이 페이지의 컴포넌트는 디자인 리뷰에서도, 스크린샷 비교에서도 보이지 않는다. 그래서 스크린 리더로 한 번 훑고 키보드로 직접 조작해 검증한다. 이 점검은 하면 좋은 일이 아니라 릴리스 전에 반드시 통과해야 하는 관문이다. 없는 id를 가리키는 건너뛰기 링크, 포커스를 돌려주지 않는 포커스 트랩, 조건부로 렌더링한 라이브 영역 모두 QA 스크린샷에서는 멀쩡해 보인다.

알려진 공백

  • 건너뛰기 링크 대상, 포커스 반환, 라이브 영역 사전 마운트를 확인하는 자동 테스트가 없다.
  • <VisuallyHidden>만 문서화돼 있고, 나머지 네 유틸리티는 컴포넌트 API 문서가 없다.
  • 열린 시트 뒤 콘텐츠를 inert로 처리하는 동작은 설명돼 있지만, 구체적인 스펙은 없다.