Skip to Content
PatternsAccessibility Patterns

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

✅ 권장사항

  1. 시맨틱 HTML 우선: 적절한 HTML 요소 사용 (button, nav, main, article 등)
  2. 키보드 접근성: 모든 인터랙티브 요소에 Tab, Enter, Space 지원
  3. 명확한 레이블: 모든 input에 label 연결, aria-label 제공
  4. 색상 대비: WCAG AA (4.5:1) 이상 유지
  5. 포커스 표시: 명확한 포커스 링 제공
  6. 에러 메시지: role=“alert”로 즉시 알림
  7. 스크린 리더 테스트: NVDA, JAWS, VoiceOver로 직접 테스트

⚠️ 피해야 할 것

  1. div/span 버튼: button 대신 div 사용
  2. 색상만으로 정보 전달: 색맹 사용자 고려
  3. placeholder만으로 레이블 대체: label 필수
  4. 자동 포커스 이동: 사용자 제어 박탈
  5. 키보드 트랩: Escape로 닫을 수 있어야 함
  6. outline 제거: 포커스 표시 필수
  7. 타임아웃: 충분한 시간 제공 또는 연장 옵션

접근성 체크리스트

// 접근성 자가 진단 체크리스트 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

관련 패턴

Last updated on