Communitygithub.com

프론트엔드 접근성 패턴

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

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

이 스킬은 React와 Next.js 애플리케이션에서 접근성을 구현하기 위한 포괄적인 패턴 및 안티 패턴 모음입니다. 프론트엔드 개발자가 코드 리뷰에서 가장 흔히 발견되는 접근성 문제(누락된 폼 레이블 연결, 부적절한 ARIA 속성 사용, 비의미론적 상호작용 요소, 키보드 탐색 및 포커스 관리 누락)를 해결하는 데 도움을 줍니다. 이 스킬은 폼 접근성(레이블 연결, 필수 필드 표시, 오류 메시지 연결), 의미론적 HTML 사용, 올바른 ARIA 속성 적용(aria-label, aria-labelledby, aria-describedby, aria-live, aria-expanded 등), 키보드 탐색 구현(커스텀 드롭다운 예제 포함), 모달 포커스 관리, 이미지 및 아이콘 접근성, reduced motion 대응을 다룹니다. 모든 코드 예제는 TypeScript 기반 React 컴포넌트로 제공되며, 체크리스트를 통해 리뷰 전 확인 사항을 명확히 제시합니다. 주로 Claude Code, Cursor 등의 AI 코딩 도우미와 함께 사용하도록 설계되었습니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/ECC/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: label과 input이 연결되지 않음 — 스크린 리더가 연관지을 수 없음
<label>Email</label>
<input type="email" />

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

필수 필드

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

// GOOD: required는 네이티브 브라우저 검증 활성화; aria-required는 스크린 리더에 신호 전달
<label htmlFor="email">
  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">Invalid email address</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 = 'Email is required';
    if (!password) newErrors.password = 'Password is required';
    if (Object.keys(newErrors).length) {
      setErrors(newErrors);
      return;
    }
    onSubmit(email, password);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">
          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">
          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">Log in</button>
    </form>
  );
}

의미론적 HTML

의도와 일치하는 요소를 사용하세요. 스크린 리더와 키보드 사용자는 네이티브 의미론에 의존합니다.

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

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

// GOOD: a 링크는 우클릭, 중간 클릭, 키보드 탐색 지원
<a href="/home">Home</a>
// BAD: 제목 계층 건너뜀 (h1에서 h4로)
<h1>Dashboard</h1>
<h4>Recent Activity</h4>

// GOOD: 순차적인 제목 수준
<h1>Dashboard</h1>
<h2>Recent Activity</h2>

ARIA 속성

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

aria-label vs aria-labelledby

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

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

aria-describedby

// 레이블을 넘어 추가 설명 제공
<button
  aria-describedby="delete-warning"
  onClick={handleDelete}
> Delete account
</button>
<p id="delete-warning">This action cannot be undone.</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}>Close</button>
    </div>
  );
}

이미지 및 아이콘

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

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

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

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

동작 줄이기 (Reduced Motion)

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

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}>Click me</div>

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

// BAD: placeholder를 label 대용으로 사용
<input placeholder="Enter your email" />

// BAD: 양수 tabIndex는 예측 불가능한 탭 순서 생성
<button tabIndex={3}>Submit</button>

// BAD: 포커스 가능한 요소에 aria-hidden — 키보드 사용자 트랩 발생
<button aria-hidden="true">Open</button>

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

체크리스트

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

  • 모든 <input>, <select>, <textarea>에 htmlFor/id를 통해 연결된 <label>이 있는가
  • 오류 메시지가 aria-describedby로 연결되고 role="alert"로 표시되었는가
  • role, tabIndex, onKeyDown 없이 <div>나 <span>에 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/content-engine

Create platform-native content systems for X, LinkedIn, TikTok, YouTube, newsletters, and repurposed multi-platform campaigns. Use when the user wants social posts, threads, scripts, content calendars, or one source asset adapted cleanly across platforms.

affaan-m/ECC

Multi-platform content distribution across X, LinkedIn, Threads, and Bluesky. Adapts content per platform using content-engine patterns. Never posts identical content cross-platform. Use when the user wants to distribute content across social platforms.

affaan-m/fal-ai-media

Unified media generation via fal.ai MCP — image, video, and audio. Covers text-to-image (Nano Banana), text/image-to-video (Seedance, Kling, Veo 3), text-to-speech (CSM-1B), and video-to-audio (ThinkSound). Use when the user wants to generate images, videos, or audio with AI.

affaan-m/manim-video

日本語翻訳:このファイルは manim-video 用の日本語翻訳が必要です

affaan-m/remotion-video-creation

Remotion のベストプラクティス - React で動画を作成する。3D、アニメーション、音声、字幕、チャート、トランジションなどをカバーするドメイン固有の29のルール。

affaan-m/video-editing

AI-assisted video editing workflows for cutting, structuring, and augmenting real footage. Covers the full pipeline from raw capture through FFmpeg, Remotion, ElevenLabs, fal.ai, and final polish in Descript or CapCut. Use when the user wants to edit video, cut footage, create vlogs, or build video content.

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.

agent-self-evaluation

Use after completing any non-trivial task. The agent self-rates its output on 5 axes — accuracy, completeness, clarity, actionability, conciseness — with concrete evidence per criterion. Produces a structured 1-5 scorecard with specific improvement suggestions.

agent-sort

Build an evidence-backed ECC install plan for a specific repo by sorting skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using parallel repo-aware review passes. Use when ECC should be trimmed to what a project actually needs instead of loading the full bundle.

ai-first-engineering

Engineering operating model for teams where AI agents generate a large share of implementation output. Use when setting team process, review gates, or ownership rules for a codebase largely written by agents.

ai-regression-testing

Regression testing strategies for AI-assisted development. Sandbox-mode API testing without database dependencies, automated bug-check workflows, and patterns to catch AI blind spots where the same model writes and reviews code. Use when adding regression coverage to AI-assisted code, or when the same model both wrote and reviewed a change.

android-clean-architecture

Clean Architecture patterns for Android and Kotlin Multiplatform projects — module structure, dependency rules, UseCases, Repositories, and data layer patterns. Use when structuring modules, layers, or data flow in an Android or KMP project.

angular-developer

Generates Angular code and provides architectural guidance. Trigger when creating projects, components, or services, or for best practices on reactivity (signals, linkedSignal, resource), forms, dependency injection, routing, SSR, accessibility (ARIA), animations, styling (component styles, Tailwind CSS), testing, or CLI tooling.

관련 스킬