Accessibility Patterns
모든 사용자가 애플리케이션을 사용할 수 있도록 접근성을 보장하는 패턴입니다.
개요
접근성(Accessibility)은 다음과 같은 경우에 필요합니다:
- 스크린 리더 지원: 시각 장애인이 콘텐츠를 이해할 수 있도록
- 키보드 네비게이션: 마우스 없이도 모든 기능 사용 가능
- 색상 대비: 저시력 사용자를 위한 충분한 색상 대비
- 포커스 관리: 명확한 포커스 표시와 논리적인 포커스 흐름
- 시맨틱 HTML: 의미 있는 HTML 요소 사용
WCAG 2.1 AA 수준 준수를 목표로 합니다.
기본 패턴
1. 시맨틱 HTML과 ARIA 속성
올바른 HTML 요소와 ARIA 속성을 사용합니다.
// src/components/AccessibleButton.tsx
interface AccessibleButtonProps {
children: React.ReactNode;
onClick: () => void;
loading?: boolean;
disabled?: boolean;
variant?: "primary" | "secondary" | "danger";
}
export function AccessibleButton({
children,
onClick,
loading = false,
disabled = false,
variant = "primary",
}: AccessibleButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
aria-busy={loading}
aria-disabled={disabled || loading}
className={`btn btn-${variant}`}
>
{loading && (
<span className="spinner" role="status" aria-label="로딩 중">
<span className="sr-only">로딩 중...</span>
</span>
)}
<span aria-hidden={loading}>{children}</span>
</button>
);
}
// src/components/AccessibleForm.tsx
export function AccessibleForm() {
return (
<form onSubmit={handleSubmit}>
{/* label과 input 연결 */}
<div className="form-group">
<label htmlFor="email">
이메일 <span aria-label="필수 입력">*</span>
</label>
<input
id="email"
type="email"
name="email"
required
aria-required="true"
aria-describedby="email-hint email-error"
aria-invalid={errors.email ? "true" : "false"}
/>
<div id="email-hint" className="hint">
예: user@example.com
</div>
{errors.email && (
<div id="email-error" className="error" role="alert">
{errors.email}
</div>
)}
</div>
{/* 라디오 버튼 그룹 */}
<fieldset>
<legend>성별</legend>
<div>
<input
type="radio"
id="gender-male"
name="gender"
value="male"
checked={gender === "male"}
onChange={handleGenderChange}
/>
<label htmlFor="gender-male">남성</label>
</div>
<div>
<input
type="radio"
id="gender-female"
name="gender"
value="female"
checked={gender === "female"}
onChange={handleGenderChange}
/>
<label htmlFor="gender-female">여성</label>
</div>
</fieldset>
<button type="submit">제출</button>
</form>
);
}2. 키보드 네비게이션
모든 인터랙티브 요소에 키보드 접근을 제공합니다.
// src/components/KeyboardAccessibleMenu.tsx
import { useState, useRef, useEffect, KeyboardEvent } from "react";
interface MenuItem {
id: string;
label: string;
onClick: () => void;
}
interface KeyboardAccessibleMenuProps {
items: MenuItem[];
trigger: React.ReactNode;
}
export function KeyboardAccessibleMenu({
items,
trigger,
}: KeyboardAccessibleMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Escape로 메뉴 닫기
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
setIsOpen(false);
}
};
document.addEventListener("keydown", handleEscape as any);
return () => document.removeEventListener("keydown", handleEscape as any);
}, [isOpen]);
// 메뉴 열릴 때 첫 번째 항목에 포커스
useEffect(() => {
if (isOpen && itemRefs.current[0]) {
itemRefs.current[0].focus();
}
}, [isOpen]);
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setFocusedIndex((prev) => {
const next = (prev + 1) % items.length;
itemRefs.current[next]?.focus();
return next;
});
break;
case "ArrowUp":
e.preventDefault();
setFocusedIndex((prev) => {
const next = (prev - 1 + items.length) % items.length;
itemRefs.current[next]?.focus();
return next;
});
break;
case "Home":
e.preventDefault();
setFocusedIndex(0);
itemRefs.current[0]?.focus();
break;
case "End":
e.preventDefault();
setFocusedIndex(items.length - 1);
itemRefs.current[items.length - 1]?.focus();
break;
case "Enter":
case " ":
e.preventDefault();
items[focusedIndex].onClick();
setIsOpen(false);
break;
}
};
return (
<div className="menu-container">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="menu"
aria-controls="dropdown-menu"
>
{trigger}
</button>
{isOpen && (
<div
ref={menuRef}
id="dropdown-menu"
role="menu"
className="menu-dropdown"
>
{items.map((item, index) => (
<button
key={item.id}
ref={(el) => (itemRefs.current[index] = el)}
role="menuitem"
tabIndex={-1}
onClick={() => {
item.onClick();
setIsOpen(false);
}}
onKeyDown={handleKeyDown}
className="menu-item"
>
{item.label}
</button>
))}
</div>
)}
</div>
);
}3. 포커스 관리
포커스를 적절히 관리하여 사용자 경험을 향상시킵니다.
// src/hooks/useFocusTrap.ts
import { useEffect, useRef } from "react";
export function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive) return;
const container = containerRef.current;
if (!container) return;
// 포커스 가능한 모든 요소 찾기
const focusableElements = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// 첫 번째 요소에 포커스
firstElement?.focus();
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
// Shift + Tab: 역방향
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab: 정방향
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
container.addEventListener("keydown", handleTab as any);
return () => container.removeEventListener("keydown", handleTab as any);
}, [isActive]);
return containerRef;
}
// src/components/AccessibleModal.tsx
export function AccessibleModal({
isOpen,
onClose,
title,
children,
}: {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
const containerRef = useFocusTrap(isOpen);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 모달 열릴 때 현재 포커스 저장
previousFocusRef.current = document.activeElement as HTMLElement;
} else {
// 모달 닫힐 때 이전 포커스로 복원
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose} role="presentation">
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="모달 닫기"
className="close-button"
>
×
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
}고급 패턴
1. 스크린 리더 전용 콘텐츠
시각적으로 숨기지만 스크린 리더에는 읽히는 콘텐츠를 제공합니다.
/* src/styles/accessibility.css */
/* 스크린 리더 전용 클래스 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* 포커스 시 표시 */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
/* 포커스 링 (접근성 필수) */
*:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
*:focus:not(:focus-visible) {
outline: none;
}
*:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}// src/components/SkipNavigation.tsx
export function SkipNavigation() {
return (
<a
href="#main-content"
className="sr-only-focusable"
style={{
position: "absolute",
top: 0,
left: 0,
padding: "1rem",
backgroundColor: "#0066cc",
color: "white",
zIndex: 9999,
}}
>
본문으로 건너뛰기
</a>
);
}
// src/components/AccessibleIcon.tsx
export function AccessibleIcon({
icon,
label,
}: {
icon: React.ReactNode;
label: string;
}) {
return (
<>
<span aria-hidden="true">{icon}</span>
<span className="sr-only">{label}</span>
</>
);
}
// 사용 예시
export function DeleteButton() {
return (
<button onClick={handleDelete}>
<AccessibleIcon icon={<TrashIcon />} label="삭제" />
</button>
);
}2. Live Region으로 동적 콘텐츠 알림
스크린 리더에 동적 변경사항을 알립니다.
// src/components/LiveRegion.tsx
interface LiveRegionProps {
message: string;
politeness?: "polite" | "assertive" | "off";
clearAfter?: number;
}
export function LiveRegion({
message,
politeness = "polite",
clearAfter = 0,
}: LiveRegionProps) {
const [announcement, setAnnouncement] = useState(message);
useEffect(() => {
setAnnouncement(message);
if (clearAfter > 0) {
const timer = setTimeout(() => setAnnouncement(""), clearAfter);
return () => clearTimeout(timer);
}
}, [message, clearAfter]);
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
);
}
// src/components/FormWithAnnouncements.tsx
export function FormWithAnnouncements() {
const [announcement, setAnnouncement] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 유효성 검사
const newErrors = validateForm();
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
setAnnouncement(
`폼 검증 실패: ${Object.keys(newErrors).length}개의 오류가 있습니다`
);
return;
}
try {
await submitForm();
setAnnouncement("폼이 성공적으로 제출되었습니다");
} catch (error) {
setAnnouncement("폼 제출 중 오류가 발생했습니다");
}
};
return (
<>
<LiveRegion
message={announcement}
politeness="assertive"
clearAfter={5000}
/>
<form onSubmit={handleSubmit}>{/* 폼 필드 */}</form>
</>
);
}
// src/components/LoadingAnnouncement.tsx
export function LoadingAnnouncement({ isLoading }: { isLoading: boolean }) {
return (
<LiveRegion
message={isLoading ? "콘텐츠를 불러오는 중입니다" : "콘텐츠 로딩 완료"}
politeness="polite"
/>
);
}3. 색상 대비와 다크 모드
WCAG AA 기준(4.5:1)을 만족하는 색상 대비를 제공합니다.
/* src/styles/colors.css */
:root {
/* Light mode - WCAG AA 이상 */
--color-text-primary: #1a1a1a; /* 대비 16.7:1 */
--color-text-secondary: #4a4a4a; /* 대비 9.7:1 */
--color-background: #ffffff;
--color-primary: #0066cc; /* 대비 4.5:1 */
--color-primary-hover: #0052a3; /* 대비 5.8:1 */
--color-error: #d32f2f; /* 대비 5.5:1 */
--color-success: #2e7d32; /* 대비 4.7:1 */
--color-border: #d0d0d0; /* 대비 2.8:1 */
/* Focus styles */
--color-focus: #0066cc;
--focus-width: 2px;
--focus-offset: 2px;
}
[data-theme="dark"] {
/* Dark mode - WCAG AA 이상 */
--color-text-primary: #f0f0f0; /* 대비 14.6:1 */
--color-text-secondary: #b0b0b0; /* 대비 8.6:1 */
--color-background: #1a1a1a;
--color-primary: #4d94ff; /* 대비 4.5:1 */
--color-primary-hover: #66a3ff; /* 대비 5.5:1 */
--color-error: #ff6b6b; /* 대비 4.5:1 */
--color-success: #51cf66; /* 대비 5.1:1 */
--color-border: #404040; /* 대비 2.8:1 */
--color-focus: #4d94ff;
}
/* High contrast mode */
@media (prefers-contrast: high) {
:root {
--color-text-primary: #000000;
--color-background: #ffffff;
--color-primary: #0000cc;
--color-border: #000000;
}
[data-theme="dark"] {
--color-text-primary: #ffffff;
--color-background: #000000;
--color-primary: #6699ff;
--color-border: #ffffff;
}
}// src/components/ColorContrastChecker.tsx
export function ColorContrastChecker() {
const [foreground, setForeground] = useState("#1a1a1a");
const [background, setBackground] = useState("#ffffff");
const [ratio, setRatio] = useState(0);
useEffect(() => {
const fg = hexToRgb(foreground);
const bg = hexToRgb(background);
const contrast = calculateContrastRatio(fg, bg);
setRatio(contrast);
}, [foreground, background]);
const meetsAA = ratio >= 4.5;
const meetsAAA = ratio >= 7;
return (
<div className="contrast-checker">
<div className="inputs">
<label>
전경색:
<input
type="color"
value={foreground}
onChange={(e) => setForeground(e.target.value)}
/>
</label>
<label>
배경색:
<input
type="color"
value={background}
onChange={(e) => setBackground(e.target.value)}
/>
</label>
</div>
<div
className="preview"
style={{
color: foreground,
backgroundColor: background,
padding: "2rem",
}}
>
샘플 텍스트
</div>
<div className="results">
<p>대비 비율: {ratio.toFixed(2)}:1</p>
<p>WCAG AA: {meetsAA ? "✅ 통과" : "❌ 실패"}</p>
<p>WCAG AAA: {meetsAAA ? "✅ 통과" : "❌ 실패"}</p>
</div>
</div>
);
}
function hexToRgb(hex: string) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 };
}
function calculateContrastRatio(
fg: { r: number; g: number; b: number },
bg: { r: number; g: number; b: number }
) {
const l1 = getLuminance(fg);
const l2 = getLuminance(bg);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
function getLuminance({ r, g, b }: { r: number; g: number; b: number }) {
const [rs, gs, bs] = [r, g, b].map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}4. 접근 가능한 데이터 테이블
복잡한 데이터 테이블을 접근 가능하게 만듭니다.
// src/components/AccessibleTable.tsx
interface TableColumn<T> {
id: string;
header: string;
accessor: (row: T) => React.ReactNode;
sortable?: boolean;
}
interface AccessibleTableProps<T> {
data: T[];
columns: TableColumn<T>[];
caption: string;
sortBy?: string;
sortDirection?: "asc" | "desc";
onSort?: (columnId: string) => void;
}
export function AccessibleTable<T extends { id: string }>({
data,
columns,
caption,
sortBy,
sortDirection,
onSort,
}: AccessibleTableProps<T>) {
return (
<table className="accessible-table">
<caption className="table-caption">{caption}</caption>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.id}
scope="col"
aria-sort={
sortBy === column.id
? sortDirection === "asc"
? "ascending"
: "descending"
: undefined
}
>
{column.sortable ? (
<button
type="button"
onClick={() => onSort?.(column.id)}
className="sort-button"
aria-label={`${column.header} 기준으로 정렬`}
>
{column.header}
{sortBy === column.id && (
<span aria-hidden="true">
{sortDirection === "asc" ? " ↑" : " ↓"}
</span>
)}
</button>
) : (
column.header
)}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.id}>
{columns.map((column, index) => (
<td key={column.id} {...(index === 0 ? { scope: "row" } : {})}>
{column.accessor(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// 사용 예시
export function UserTable() {
const [sortBy, setSortBy] = useState("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const columns: TableColumn<User>[] = [
{
id: "name",
header: "이름",
accessor: (user) => user.name,
sortable: true,
},
{
id: "email",
header: "이메일",
accessor: (user) => user.email,
sortable: true,
},
{
id: "role",
header: "역할",
accessor: (user) => user.role,
},
{
id: "actions",
header: "작업",
accessor: (user) => (
<button
onClick={() => handleEdit(user.id)}
aria-label={`${user.name} 수정`}
>
수정
</button>
),
},
];
const handleSort = (columnId: string) => {
if (sortBy === columnId) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortBy(columnId);
setSortDirection("asc");
}
};
return (
<AccessibleTable
data={users}
columns={columns}
caption="사용자 목록"
sortBy={sortBy}
sortDirection={sortDirection}
onSort={handleSort}
/>
);
}Best Practices
✅ 권장사항
- 시맨틱 HTML 우선: 적절한 HTML 요소 사용 (button, nav, main, article 등)
- 키보드 접근성: 모든 인터랙티브 요소에 Tab, Enter, Space 지원
- 명확한 레이블: 모든 input에 label 연결, aria-label 제공
- 색상 대비: WCAG AA (4.5:1) 이상 유지
- 포커스 표시: 명확한 포커스 링 제공
- 에러 메시지: role=“alert”로 즉시 알림
- 스크린 리더 테스트: NVDA, JAWS, VoiceOver로 직접 테스트
⚠️ 피해야 할 것
- div/span 버튼: button 대신 div 사용
- 색상만으로 정보 전달: 색맹 사용자 고려
- placeholder만으로 레이블 대체: label 필수
- 자동 포커스 이동: 사용자 제어 박탈
- 키보드 트랩: Escape로 닫을 수 있어야 함
- outline 제거: 포커스 표시 필수
- 타임아웃: 충분한 시간 제공 또는 연장 옵션
접근성 체크리스트
// 접근성 자가 진단 체크리스트
const a11yChecklist = {
perceivable: [
"텍스트에 대한 대체 텍스트 제공",
"색상만으로 정보 전달하지 않음",
"충분한 색상 대비 (4.5:1 이상)",
"텍스트 크기 조절 가능 (200%까지)",
"자동 재생되는 오디오/비디오 없음",
],
operable: [
"모든 기능을 키보드로 사용 가능",
"키보드 트랩 없음",
"충분한 시간 제공",
"깜빡이는 콘텐츠 없음 (초당 3회 이하)",
"명확한 링크 텍스트",
],
understandable: [
"페이지 언어 설정 (lang 속성)",
"일관된 네비게이션",
"명확한 에러 메시지",
"레이블과 설명 제공",
"예측 가능한 동작",
],
robust: [
"유효한 HTML",
"ARIA 속성 올바른 사용",
"보조 기술 호환성",
"이름, 역할, 값 제공",
],
};제품별 접근성 전략
Foundation (기본 프레임워크)
// Foundation용 접근성 유틸리티
export function announceToScreenReader(message: string) {
const announcement = document.createElement("div");
announcement.setAttribute("role", "status");
announcement.setAttribute("aria-live", "polite");
announcement.className = "sr-only";
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => document.body.removeChild(announcement), 1000);
}전체 코드 보기: Foundation A11y Utils
iCignal (분석 플랫폼)
// iCignal용 접근 가능한 차트
export function AccessibleChart({ data }: { data: ChartData[] }) {
return (
<figure>
<figcaption id="chart-title">월별 매출 추이</figcaption>
<div
role="img"
aria-labelledby="chart-title"
aria-describedby="chart-desc"
>
<Chart data={data} />
</div>
<div id="chart-desc" className="sr-only">
{`2024년 1월부터 12월까지의 매출 데이터. 최고 매출은 ${Math.max(
...data.map((d) => d.value)
)}원입니다.`}
</div>
{/* 데이터 테이블 대안 제공 */}
<details>
<summary>데이터 테이블로 보기</summary>
<AccessibleTable data={data} />
</details>
</figure>
);
}전체 코드 보기: iCignal Accessible Charts
Cals (예약 시스템)
// Cals용 접근 가능한 캘린더
export function AccessibleCalendar() {
return (
<div role="application" aria-label="예약 캘린더">
<button aria-label="이전 달로 이동" onClick={handlePrevMonth}>
←
</button>
<h2 aria-live="polite">{currentMonth}</h2>
<button aria-label="다음 달로 이동" onClick={handleNextMonth}>
→
</button>
<table role="grid" aria-labelledby="calendar-title">
{/* 캘린더 날짜 */}
</table>
</div>
);
}전체 코드 보기: Cals Accessible Calendar
테스트 및 실행
CodeSandbox에서 실행
Accessibility Patterns 예제 실행하기
로컬 실행
# 1. 접근성 테스트 도구 설치
pnpm add -D @axe-core/react axe-playwright
# 2. 자동 접근성 테스트
pnpm test:a11y
# 3. Lighthouse 접근성 점수
pnpm lighthouse --only-categories=accessibility관련 패턴
- Testing Patterns - 접근성 자동 테스트
- Theming - 색상 대비와 다크 모드
- Responsive Design - 모바일 접근성
Last updated on