Communitygithub.com

프론트엔드 접근성 패턴

React 및 Next.js를 위한 실용적인 접근성 패턴입니다. 코드 리뷰에서 가장 자주 지적되는 문제(누락된 폼 레이블, 잘못된 ARIA 사용, 비의미론적 인터랙티브 요소, 깨진 키보드 탐색)를 다룹니다.

프론트엔드 접근성 패턴란 무엇인가요?

이 스킬은 React와 Next.js 애플리케이션에서 접근성 문제를 해결하기 위한 실용적인 패턴을 제공합니다. 폼 레이블 연결, ARIA 속성 사용법, 시맨틱 HTML, 키보드 탐색 및 포커스 관리 등 코드 리뷰에서 가장 자주 지적되는 8가지 주요 접근성 영역을 다룹니다. 각 패턴은 TypeScript 코드 예제와 함께 'Bad vs Good' 방식으로 제공되어, 개발자가 올바른 구현 방법을 즉시 적용할 수 있도록 돕습니다. 또한 포커스 트래핑(focus-trap-react 라이브러리 활용), aria-live를 통한 동적 콘텐츠 알림, prefers-reduced-motion을 존중하는 모션 패턴 등 고급 주제도 포함합니다. 이 스킬은 Cursor, Claude Code, Codex, Windsurf 등 모든 주요 AI 코딩 어시스턴트에서 사용할 수 있으며, 접근성 관련 코드 리뷰 도구(CodeRabbit, ESLint a11y)의 피드백에 대응하거나 접근성 요구사항이 있는 컴포넌트를 처음부터 구축할 때 특히 유용합니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/everything-claude-code/tree/main/skills/frontend-a11y

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

미리보기

스킬 README에서

프론트엔드 접근성 패턴 preview
프론트엔드 접근성 패턴 preview
프론트엔드 접근성 패턴 preview

문서

프론트엔드 접근성 패턴은(는) 무엇을 하나요?

React와 Next.js를 위한 실용적인 접근성 패턴입니다. 코드 리뷰에서 가장 자주 지적되는 문제(누락된 폼 레이블, 잘못된 ARIA 사용, 비의미론적 인터랙티브 요소, 깨진 키보드 탐색)를 다룹니다.

활성화 시점

  • 폼 컴포넌트(<input>, <select>, <textarea>)를 빌드하거나 리뷰할 때
  • 인터랙티브 요소(모달, 드롭다운, 툴팁, 탭)를 만들 때
  • onClick과 함께 <div> 또는 <span>을 사용할 때
  • 요소에 aria-* 속성을 추가할 때
  • 키보드 탐색 또는 포커스 관리를 구현할 때
  • 코드 리뷰 도구(CodeRabbit, ESLint a11y)로부터 접근성 피드백을 받을 때
  • 스크린 리더를 지원해야 하는 컴포넌트를 빌드할 때

폼 접근성

htmlFor / id 연결 누락 및 분리된 오류 메시지는 코드 리뷰에서 가장 자주 지적되는 문제입니다.

레이블 연결

// BAD: 레이블이 input과 연결되지 않음 — 스크린 리더가 연관시킬 수 없음
<label>이메일</label>
<input type="email" />

// GOOD: htmlFor가 input id와 일치함
<label htmlFor="email">이메일</label>
<input id="email" type="email" />

필수 입력 필드

// BAD: 시각적 별표만으로 스크린 리더에 아무 정보도 전달되지 않음
<label htmlFor="email">이메일 *</label>
<input id="email" type="email" />

// GOOD: required는 브라우저 내장 유효성 검사를 활성화하고, aria-required는 스크린 리더에 신호를 보냄
<label htmlFor="email">
  이메일 <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />

오류 메시지

// BAD: 오류 텍스트가 시각적으로는 존재하지만 input과 연결되지 않음
<input id="email" type="email" />
<span className="error">잘못된 이메일 주소입니다</span>

// GOOD: aria-describedby가 input을 오류 메시지에 연결함
// aria-invalid가 스크린 리더에 잘못된 상태를 신호로 전달함
<input
  id="email"
  type="email"
  aria-describedby="email-error"
  aria-invalid={!!error}
/>
{error && (
  <span id="email-error" role="alert">
    {error}
  </span>
)}

완전한 접근성 폼

interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const newErrors: typeof errors = {};
    if (!email) newErrors.email = '이메일을 입력해주세요';
    if (!password) newErrors.password = '비밀번호를 입력해주세요';
    if (Object.keys(newErrors).length) {
      setErrors(newErrors);
      return;
    }
    onSubmit(email, password);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">
          이메일 <span aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          aria-required="true"
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={!!errors.email}
          autoComplete="email"
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">
          비밀번호 <span aria-hidden="true">*</span>
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          aria-required="true"
          aria-describedby={errors.password ? 'password-error' : undefined}
          aria-invalid={!!errors.password}
          autoComplete="current-password"
        />
        {errors.password && (
          <span id="password-error" role="alert">
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">로그인</button>
    </form>
  );
}

시맨틱 HTML

의도에 맞는 요소를 사용하세요. 스크린 리더와 키보드 사용자는 네이티브 시맨틱에 의존합니다.

// BAD: role, 키보드 지원, 접근 가능한 이름이 없는 div
<div onClick={handleClick}>제출</div>

// GOOD: button은 포커스 가능하고, Enter/Space로 활성화되며, "button"으로 알림
<button type="button" onClick={handleClick}>제출</button>
// BAD: 비의미론적 탐색
<div onClick={() => navigate('/home')}>홈</div>

// GOOD: anchor는 우클릭, 중간 클릭, 키보드 탐색 지원
<a href="/home">홈</a>
// BAD: 제목 계층 생략 (h1에서 h4로)
<h1>대시보드</h1>
<h4>최근 활동</h4>

// GOOD: 순차적인 제목 수준
<h1>대시보드</h1>
<h2>최근 활동</h2>

ARIA 속성

네이티브 HTML 시맨틱이 충분하지 않을 때만 ARIA를 사용하세요. 잘못된 ARIA는 ARIA가 없는 것보다 나쁩니다.

aria-label vs aria-labelledby

// aria-label: 인라인 문자열 레이블 — 보이는 레이블 텍스트가 없을 때 사용
<button aria-label="모달 닫기">
  <XIcon />
</button>

// aria-labelledby: 다른 요소의 텍스트를 참조 — 보이는 레이블이 있을 때 사용
<section aria-labelledby="section-title">
  <h2 id="section-title">최근 주문</h2>
  {/* 내용 */}
</section>

aria-describedby

// 레이블 외에 추가 설명 제공
<button
  aria-describedby="삭제-경고"
  onClick={handleDelete}
> 계정 삭제
</button>
<p id="삭제-경고">이 작업은 되돌릴 수 없습니다.</p>

동적 콘텐츠를 위한 aria-live

// 페이지 새로고침 없이 업데이트되는 콘텐츠를 알리려면 aria-live 사용
// polite: 사용자가 현재 작업을 마칠 때까지 기다렸다가 알림
// assertive: 즉시 중단 — 긴급 오류에만 사용

export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
  return (
    <div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
      {message}
    </div>
  );
}

aria-expanded와 aria-controls

export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  const contentId = useId();

  return (
    <div>
      <button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
        {title}
      </button>
      <div id={contentId} hidden={!isOpen}>
        {children}
      </div>
    </div>
  );
}

키보드 탐색

모든 인터랙티브 요소는 키보드만으로 접근 가능하고 작동 가능해야 합니다.

커스텀 드롭다운

export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const listId = useId();

  if (!options.length) return null;

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(i => Math.min(i + 1, options.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen) onSelect(options[activeIndex]);
        setIsOpen(prev => !prev);
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  };

  return (
    <div
      role="combobox"
      aria-expanded={isOpen}
      aria-haspopup="listbox"
      aria-controls={listId}
      tabIndex={0}
      onKeyDown={handleKeyDown}
      onClick={() => setIsOpen(prev => !prev)}
    >
      <span>{options[activeIndex]}</span>
      {isOpen && (
        <ul id={listId} role="listbox">
          {options.map((option, index) => (
            <li
              key={option}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => {
                onSelect(option);
                setIsOpen(false);
              }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

포커스 관리

UI 상태가 변경될 때(특히 모달 및 경로 전환) 포커스가 논리적으로 이동해야 합니다.

모달 포커스 복원

이 예제는 초기 포커스와 복원을 다룹니다. 전체 포커스 트랩(모달 내 Tab/Shift+Tab 순환)을 위해서는 동적 콘텐츠 및 중첩 포털과 같은 엣지 케이스를 처리하는 focus-trap-react와 같은 라이브러리를 사용하세요.

export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 현재 포커스된 요소를 저장하고 모달로 포커스 이동
      previousFocusRef.current = document.activeElement as HTMLElement;
      modalRef.current?.focus();
    } else {
      // 모달을 연 요소로 포커스 복원
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
      <h2 id="modal-title">{title}</h2>
      {children}
      <button onClick={onClose}>닫기</button>
    </div>
  );
}

이미지 및 아이콘

// BAD: 장식용 아이콘이 레이블 없는 이미지로 알림됨
<img src="/icon.svg" />

// GOOD: 장식용 이미지가 스크린 리더에서 숨겨짐
<img src="/decoration.png" alt="" aria-hidden="true" />

// GOOD: 설명적인 대체 텍스트가 있는 의미 있는 이미지
<img src="/chart.png" alt="월별 수익이 1월부터 3월까지 23% 증가했습니다" />

// GOOD: 접근 가능한 레이블이 있는 아이콘 버튼
<button aria-label="항목 삭제">
  <TrashIcon aria-hidden="true" />
</button>

동작 줄이기

운영 체제 설정에서 동작 줄이기를 요청한 사용자를 존중하세요.

export function useReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setPrefersReduced(mq.matches);
    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return prefersReduced;
}

// 사용법
export function AnimatedCard({ children }: { children: React.ReactNode }) {
  const reduceMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: reduceMotion ? 'none' : 'transform 300ms ease'
      }}
    >
      {children}
    </div>
  );
}

안티 패턴

// BAD: 키보드 지원이 없는 비인터랙티브 요소의 onClick
<div onClick={handleClick}>클릭하세요</div>

// BAD: role이 없는 div에 aria-label 사용
<div aria-label="탐색">...</div>

// BAD: 레이블 대신 placeholder 사용
<input placeholder="이메일을 입력하세요" />

// BAD: 양수 tabIndex는 예측 불가능한 탭 순서를 만듦
<button tabIndex={3}>제출</button>

// BAD: 포커스 가능한 요소에 aria-hidden — 키보드 사용자가 갇힘
<button aria-hidden="true">열기</button>

// BAD: 키보드 핸들러가 없는 div에 role="button"
<div role="button" onClick={handleClick}>제출</div>
// 누락: tabIndex={0}, Enter/Space를 위한 onKeyDown

체크리스트

리뷰를 위해 인터랙티브 컴포넌트를 제출하기 전에:

  • 모든 <input>, <select>, <textarea>가 htmlFor/id를 통해 연결된 <label>을 가지고 있는가?
  • 오류 메시지가 aria-describedby로 연결되고 role="alert"로 표시되었는가?
  • <div> 또는 <span>에 role, tabIndex, onKeyDown 없이 onClick이 사용되지 않았는가?
  • 아이콘 전용 버튼에 aria-label이 있는가?
  • 장식용 이미지에 alt=""와 aria-hidden="true"가 사용되었는가?
  • 모달이 닫힐 때 포커스를 복원하는가? (Tab/Shift+Tab 순환을 통한 전체 포커스 트래핑은 focus-trap-react와 같은 라이브러리 사용)
  • 동적 콘텐츠 업데이트에 aria-live가 사용되었는가?
  • 애니메이션에 대해 prefers-reduced-motion이 존중되는가?

관련 스킬

  • frontend-patterns — 일반적인 React 컴포넌트 및 상태 패턴
  • design-system — 디자인 토큰 및 컴포넌트 일관성
  • motion-foundations 및 motion-patterns: 접근성 고려 사항이 포함된 애니메이션 패턴

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

accessibility

Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA. Use when building or auditing UI that must meet WCAG 2.2 Level AA, or when reviewing a change for keyboard, contrast, or screen-reader support.

affaan-m/claude-api

Anthropic Claude API patterns for Python and TypeScript. Covers Messages API, streaming, tool use, vision, extended thinking, batches, prompt caching, and Claude Agent SDK. Use when building applications with the Claude API or Anthropic SDKs.

affaan-m/everything-claude-code

End-to-end marketing campaign planning and execution. Covers audience research, positioning, campaign angle definition, landing page copy, email sequences, social posts, ad copy, short-form video scripts, and content calendars. Use as the orchestration layer for multi-channel product launches. Use when planning or executing a multi-channel product launch, or producing landing page, email, social, or ad copy.

affaan-m/everything-claude-code

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/everything-claude-code-conventions

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/frontend-design

Create distinctive, production-grade frontend interfaces with high design quality. Use when the user asks to build web components, pages, or applications and the visual direction matters as much as the code quality.

affaan-m/gget

gget CLI and Python workflow for quick genomic database queries, sequence lookup, BLAST-style searches, enrichment checks, and reproducible bioinformatics evidence logs.

affaan-m/literature-review

Systematic literature-review workflow for academic, biomedical, technical, and scientific topics, including search planning, source screening, synthesis, citation checks, and evidence logging.

affaan-m/motion-ui

Production-ready UI motion system for React/Next.js. Use when implementing animations, transitions, or motion patterns.

affaan-m/project-guidelines-example

Example project-specific skill template based on a real production application.

affaan-m/pubmed-database

Direct PubMed and NCBI E-utilities search workflows for biomedical literature, MeSH queries, PMID lookup, citation retrieval, and API-backed literature monitoring.

affaan-m/scholar-evaluation

Structured scholarly-work evaluation for papers, proposals, literature reviews, methods sections, evidence quality, citation support, and research-writing feedback.

affaan-m/uspto-database

USPTO patent and trademark data workflow for official record lookup, PatentSearch queries, TSDR checks, assignment data, and reproducible IP research logs.

agent-architecture-audit

Full-stack diagnostic for agent and LLM applications. Audits the 12-layer agent stack for wrapper regression, memory pollution, tool discipline failures, hidden repair loops, and rendering corruption. Produces severity-ranked findings with code-first fixes. Essential for developers building agent applications, autonomous loops, or any LLM-powered feature. Use when an agent or LLM feature misbehaves and the failing layer is unknown, or before shipping an agent stack.

agent-eval

Head-to-head comparison of coding agents (Claude Code, Aider, Codex, etc.) on custom tasks with pass rate, cost, time, and consistency metrics. Use when choosing between coding agents, or when a change to an agent setup needs measured pass rate, cost, and time rather than an impression.

agent-harness-construction

Design and optimize AI agent action spaces, tool definitions, and observation formatting for higher completion rates. Use when defining or revising an agent

agentic-engineering

Operate as an agentic engineer using eval-first execution, decomposition, and cost-aware model routing. Use when planning or executing engineering work that agents will carry out end to end.

agentic-os

Build persistent multi-agent operating systems on Claude Code. Covers kernel architecture, specialist agents, slash commands, file-based memory, scheduled automation, and state management without external databases. Use when building a persistent multi-agent system on Claude Code with its own memory, commands, and scheduling.

agent-introspection-debugging

Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry.

agent-payment-x402

Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet.

관련 스킬