Communitygithub.com

junksidetm/darkside-toolkit

React 및 Next.js를 위한 접근성 패턴 — 시맨틱 HTML, ARIA 속성, 폼 라벨링, 키보드 네비게이션, 포커스 관리, 스크린 리더 지원. 폼, 모달, 드롭다운, 툴팁, 탭을 구축하거나 리뷰할 때, a11y 린트 또는 코드 리뷰 결과를 수정할 때, 키보드 네비게이션 및 포커스 관리를 구현할 때 사용하세요.

darkside-toolkit란 무엇인가요?

이 스킬은 React와 Next.js 애플리케이션에서 접근성(a11y) 문제를 해결하기 위한 실용적인 패턴을 제공합니다. 코드 리뷰에서 가장 자주 지적되는 문제인 누락된 폼 라벨, 잘못된 ARIA 사용, 비시맨틱 인터랙티브 요소, 깨진 키보드 네비게이션 등을 구체적인 코드 예제와 함께 다룹니다. `htmlFor`/`id` 연결, `aria-describedby`를 통한 오류 메시지 연결, `aria-live` 영역, 포커스 관리, 모달 포커스 복원, `prefers-reduced-motion` 존중 등 주요 패턴을 TypeScript 코드 스니펫으로 제공합니다. 이 스킬은 Cursor, Claude Code, Codex, Windsurf, Cline, Aider, Copilot 등 다양한 AI 에이전트 플랫폼에서 사용할 수 있으며, 접근성 린트 도구(ESLint a11y, CodeRabbit)의 피드백을 해결하거나 새로운 컴포넌트를 구축할 때 활용할 수 있습니다. 또한 키보드 네비게이션(커스텀 드롭다운) 및 포커스 관리(모달)의 전체 예제를 포함하며, 반드시 피해야 할 안티패턴도 명시하여 개발자가 접근성 있는 컴포넌트를 올바르게 구현할 수 있도록 돕습니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/junksidetm/darkside-toolkit/tree/HEAD/skills/frontend-a11y

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

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

미리보기

스킬 README에서

darkside-toolkit preview
darkside-toolkit preview
darkside-toolkit preview

문서

프론트엔드 접근성 패턴

React 및 Next.js를 위한 실용적인 접근성 패턴. 코드 리뷰에서 가장 자주 지적되는 문제(누락된 폼 라벨, 잘못된 ARIA 사용, 비시맨틱 인터랙티브 요소, 깨진 키보드 네비게이션)를 다룹니다.

활성화 시점

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

폼 접근성

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

라벨 연결

// BAD: 라벨이 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 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: 의미 있는 이미지에 설명적인 대체 텍스트 제공
<img src="/chart.png" alt="Monthly revenue increased 23% from January to March" />

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

동작 줄이기

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 사용
<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 2 individual skills — each has its own dedicated page.

관련 스킬