mcpField
A
accessibility-a11y

accessibility-a11y

github.com/travisjneumanデザイン・UI106157 回インストール1 日前に更新
リポジトリ

accessibility-a11y について

スクリーンリーダーやキーボードなどの支援技術を使う人に配慮した、Web開発者向けのアクセシビリティガイドです。WCAG 2.2、ARIA、フォーカス管理、色のコントラスト、自動テストを扱います。

主な機能

  1. 01セマンティックHTMLとARIAの使用
  2. 02キーボード操作とフォーカス管理
  3. 03アクセシブルなフォームとエラーメッセージ
  4. 04動的コンテンツ向けライブリージョン
  5. 05WCAGのコントラスト基準
  6. 06自動・手動のアクセシビリティテスト

活用シーン

  1. 01アクセシブルなUIコンポーネントの構築
  2. 02既存画面の適合上の問題確認
  3. 03アクセシビリティ監査で見つかった問題の修正
  4. 04CI/CDへの自動テスト導入

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/travisjneuman/.claude --skill accessibility-a11y

インストール後、Claude Code で/accessibility-a11yを入力して読み込まれたか確認します。

詳細

アクセシビリティ(a11y)

概要

このスキルでは、スクリーンリーダー、キーボードのみでの操作、スイッチデバイス、その他の支援技術を利用する人を含め、誰もが使えるアクセシブルなウェブアプリケーションの構築を扱います。WCAG 2.2 の AA および AAA レベルへの準拠、適切な ARIA の使用、フォーカス管理、色のコントラスト、モーション低減への対応、自動テストの統合について説明します。

新しい UI コンポーネントの構築、既存のインターフェースのアクセシビリティ準拠状況の確認、a11y 監査で見つかった問題の修正、CI/CD パイプラインへの自動アクセシビリティテストの統合を行う際に、このスキルを使用してください。


基本原則

  1. まずセマンティック HTML を使う - ネイティブの HTML 要素(<button>、<nav>、<dialog>)を使えば、特別な対応をしなくてもアクセシビリティを確保できます。ARIA はセマンティクスだけでは不十分な場合の修復手段であり、適切な HTML の代替ではありません。
  2. キーボード操作を基本とする - キーボードだけで操作できないなら、それは機能していません。すべてのインタラクティブな要素にフォーカスでき、操作可能で、フォーカスが視覚的に示される必要があります。
  3. 実際の支援技術でテストする - 自動ツールで検出できるアクセシビリティの問題は約 30% です。残りは、スクリーンリーダー(VoiceOver、NVDA)やキーボードのみの操作による手動テストが必要です。
  4. プログレッシブエンハンスメントを採用する - まずアクセシブルなバージョンを構築し、その後で視覚的な拡張を重ねます。視覚のあるユーザーに見えるコンテンツを、支援技術から隠してはいけません。
  5. 色だけで情報を伝えない - 色は意味を補強できますが、唯一の指標にしてはいけません。色とあわせて、アイコン、テキストラベル、パターンを使います。

主なパターン

パターン 1:アクセシブルなモーダルダイアログ

使用する場面: メインコンテンツに戻る前に、ユーザーの操作が必要なオーバーレイ全般。

実装:

import { useRef, useEffect, useCallback } from "react";

interface DialogProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen) {
      // Store the element that had focus before opening
      previousFocusRef.current = document.activeElement as HTMLElement;
      dialog.showModal();
    } else {
      dialog.close();
      // Restore focus to the triggering element
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  // Handle Escape key (native dialog handles this, but we need cleanup)
  const handleClose = useCallback(() => {
    onClose();
  }, [onClose]);

  // Handle backdrop click
  const handleBackdropClick = useCallback(
    (e: React.MouseEvent<HTMLDialogElement>) => {
      if (e.target === dialogRef.current) {
        onClose();
      }
    },
    [onClose]
  );

  if (!isOpen) return null;

  return (
    <dialog
      ref={dialogRef}
      onClose={handleClose}
      onClick={handleBackdropClick}
      aria-labelledby="dialog-title"
      aria-describedby="dialog-description"
      className="dialog"
    >
      <div className="dialog-content" role="document">
        <header className="dialog-header">
          <h2 id="dialog-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="dialog-close"
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </header>
        <div id="dialog-description">{children}</div>
      </div>
    </dialog>
  );
}
/* Focus trap is handled natively by <dialog> showModal() */
dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

dialog .dialog-close:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

理由: ネイティブの <dialog> 要素で showModal() を使用すると、フォーカストラップ、Escape キーへの対応、適切な role="dialog" セマンティクスが自動的に提供されます。独自実装のモーダルでは、フォーカストラップの不具合がほぼ必ず発生します。ネイティブ要素を使えば、正しい動作をそのまま利用できます。


パターン 2:エラー処理に対応したアクセシブルなフォーム

使用する場面: ユーザー入力を受け取り、検証するフォーム全般。

実装:

interface FormFieldProps {
  id: string;
  label: string;
  type?: string;
  required?: boolean;
  error?: string;
  description?: string;
  value: string;
  onChange: (value: string) => void;
}

function FormField({
  id,
  label,
  type = "text",
  required = false,
  error,
  description,
  value,
  onChange,
}: FormFieldProps) {
  const descriptionId = description ? `${id}-description` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  // Build aria-describedby from available descriptions
  const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>

      {description && (
        <p id={descriptionId} className="field-description">
          {description}
        </p>
      )}

      <input
        id={id}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        required={required}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={describedBy}
        aria-required={required}
      />

      {error && (
        <p id={errorId} className="field-error" role="alert">
          <span aria-hidden="true">!</span> {error}
        </p>
      )}
    </div>
  );
}

// Form-level error summary for screen readers
function ErrorSummary({ errors }: { errors: Record<string, string> }) {
  const errorEntries = Object.entries(errors);
  if (errorEntries.length === 0) return null;

  return (
    <div role="alert" aria-labelledby="error-summary-title" className="error-summary">
      <h3 id="error-summary-title">
        {errorEntries.length} error{errorEntries.length > 1 ? "s" : ""} found
      </h3>
      <ul>
        {errorEntries.map(([field, message]) => (
          <li key={field}>
            <a href={`#${field}`}>{message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}
/* Screen-reader only class */
.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;
}

.field-error {
  color: var(--color-error);
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/* Never rely on color alone for errors - include icon */
.field-error::before {
  content: "";
  /* Error icon via background-image */
}

input[aria-invalid="true"] {
  border-color: var(--color-error);
  /* Also use a thicker border or icon, not just color */
  border-width: 2px;
}

理由: フォームは、アクセシビリティ上の問題が最もよく発生する箇所です。このパターンでは、すべてのフィールドにプログラムから認識できるラベルが付き、エラーが role="alert" によって通知され、エラーメッセージが aria-describedby によって入力欄と関連付けられます。また、エラーの概要から、キーボードユーザーが問題のあるフィールドへ直接移動できます。


パターン 3:カスタムウィジェットのキーボード操作

使用する場面: ネイティブの HTML 要素に当てはまらない、タブ、メニュー、リストボックス、コンボボックスなどのカスタムインタラクティブコンポーネントを構築する場合。

実装:

// Accessible tabs following WAI-ARIA Authoring Practices
interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

function Tabs({ tabs }: { tabs: Tab[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;

    switch (e.key) {
      case "ArrowRight":
        newIndex = (index + 1) % tabs.length;
        break;
      case "ArrowLeft":
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case "Home":
        newIndex = 0;
        break;
      case "End":
        newIndex = tabs.length - 1;
        break;
      default:
        return; // Don't prevent default for other keys
    }

    e.preventDefault();
    setActiveIndex(newIndex);

    // Move focus to the newly active tab
    const tabElement = document.getElementById(`tab-${tabs[newIndex].id}`);
    tabElement?.focus();
  };

  return (
    <div>
      <div role="tablist" aria-label="Content sections">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            id={`tab-${tab.id}`}
            role="tab"
            aria-selected={index === activeIndex}
            aria-controls={`panel-${tab.id}`}
            tabIndex={index === activeIndex ? 0 : -1}
            onClick={() => setActiveIndex(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          id={`panel-${tab.id}`}
          role="tabpanel"
          aria-labelledby={`tab-${tab.id}`}
          hidden={index !== activeIndex}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

理由: カスタムウィジェットでは、WAI-ARIA Authoring Practices で定められた想定上のキーボード操作パターンを実装する必要があります。タブでは、Tab キーではなく矢印キーでタブ間を移動します。また、非アクティブなタブには tabIndex={-1} を設定し、アクティブなタブだけが Tab キーによる移動順に含まれるようにします。これにより、スクリーンリーダーのユーザーが期待する操作モデルに沿った動作になります。


パターン 4:動的コンテンツのライブリージョン

使用する場面: ページの再読み込みなしでコンテンツが更新され、スクリーンリーダーのユーザーへの通知が必要な場合(通知、検索結果、読み込み状態など)。

実装:

// Toast notification system with live regions
function ToastContainer({ toasts }: { toasts: Toast[] }) {
  return (
    <div
      aria-live="polite"
      aria-atomic="false"
      aria-relevant="additions"
      className="toast-container"
    >
      {toasts.map((toast) => (
        <div
          key={toast.id}
          role="status"
          className={`toast toast-${toast.type}`}
        >
          <span className="toast-icon" aria-hidden="true">
            {toast.type === "success" ? "check" : "warning"}
          </span>
          <span>{toast.message}</span>
          <button
            onClick={() => dismissToast(toast.id)}
            aria-label={`Dismiss: ${toast.message}`}
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </div>
      ))}
    </div>
  );
}

// For urgent errors, use role="alert" (assertive)
function CriticalError({ message }: { message: string }) {
  return (
    <div role="alert" className="critical-error">
      {message}
    </div>
  );
}

// Search results count announcement
function SearchResults({ query, count }: { query: string; count: number }) {
  return (
    <>
      <div aria-live="polite" className="sr-only">
        {count} results found for "{query}"
      </div>
      {/* Visual results list */}
    </>
  );
}

理由: スクリーンリーダーは、視覚的な変更を検出するために DOM を監視しません。ライブリージョンを使うと、新しいコンテンツを読み上げるよう支援技術に明示的に伝えられます。緊急性のない更新(検索結果、トースト通知)には aria-live="polite" を、緊急のメッセージ(エラー、セッションの有効期限切れ)には role="alert" を使用します。


パターン 5:自動アクセシビリティテスト

使用する場面: すべてのプロジェクトで、CI/CD と開発ワークフローに組み込みます。

実装:

// Jest + axe-core for component testing
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";

expect.extend(toHaveNoViolations);

describe("LoginForm", () => {
  it("should have no accessibility violations", async () => {
    const { container } = render(<LoginForm />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it("should have no violations in error state", async () => {
    const { container } = render(<LoginForm />);

    // Trigger validation errors
    fireEvent.click(screen.getByRole("button", { name: /submit/i }));

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});
// Playwright + axe for E2E accessibility testing
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.describe("accessibility", () => {
  test("home page passes axe audit", async ({ page }) => {
    await page.goto("/");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test("modal dialog passes axe audit when open", async ({ page }) => {
    await page.goto("/dashboard");
    await page.getByRole("button", { name: "Create project" }).click();

    const results = await new AxeBuilder({ page })
      .include(".dialog")
      .analyze();

    expect(results.violations).toEqual([]);
  });
});
# GitHub Actions CI integration
name: Accessibility Audit
on: [pull_request]
jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build
      - name: Run axe accessibility tests
        run: npx playwright test --grep "accessibility"
      - name: Run pa11y on built pages
        run: |
          npx serve -s build -l 3000 &
          sleep 3
          npx pa11y-ci --config .pa11yci.json

理由: 自動テストを行うと、構造上の問題(ラベルの欠落、無効な ARIA、コントラスト不足)を一貫して早期に検出できます。CI で問題の 30%を自動検出できるほうが、0%のままリリース後に問題を発見するよりも優れています。網羅性を高めるには、手動テストと組み合わせてください。


カラーコントラスト早見表

テキストサイズWCAG AAWCAG AAA
通常のテキスト(< 18px / 14px 太字)4.5:17:1
大きなテキスト(>= 18px / 14px 太字)3:14.5:1
UI コンポーネントとグラフィックオブジェクト3:13:1
装飾要素/無効化された要素要件なし要件なし

ARIA 早見表

必要なことこれを使うこれは使わない
入力欄のラベル<label for="x"> または aria-labelledbyplaceholder のみをラベルとして使う
入力要件の説明ヒントテキストを参照する aria-describedbytitle 属性
装飾的なコンテンツを非表示にするaria-hidden="true"display: none(すべてのユーザーから非表示になる)
必須項目を示すaria-required="true" + required視覚的なアスタリスクのみ
エラーを通知するrole="alert" または aria-live="assertive"テキストの色だけを変更する
展開可能なセクションaria-expanded="true/false"カスタムの data-open 属性

アンチパターン

アンチパターン問題点よりよい方法
<button> の代わりに <div onclick> を使うキーボードで操作できず、ロールもフォーカスもないクリック可能な要素には <button> を使う
ネイティブ HTML で実現できるところに ARIA を使う複雑になり、バグが増える<div role="navigation"> ではなく <nav> を使う
tabindex="5"(正の値)自然なタブ順序が崩れるtabindex="0" または tabindex="-1" のみを使う
表示テキストと重複する aria-labelスクリーンリーダーで二度読み上げられるaria-label は非表示のラベルにのみ使う
フォーカスのアウトラインを非表示にする(outline: none)キーボードユーザーが現在位置を見失う削除するのではなく、:focus-visible をスタイル設定する
ページ読み込み時の autofocusスクリーンリーダーのユーザーを戸惑わせるユーザーが自然に移動できるようにする
テキストを画像にする拡大できず、翻訳もできないCSS でスタイルを設定した実際のテキストを使う

チェックリスト

  • すべてのインタラクティブ要素をキーボードで操作できる(Tab、Enter、Space、Escape、矢印キー)
  • フォーカス可能なすべての要素でフォーカスインジケーターが表示される(:focus-visible)
  • すべての画像に説明的な alt テキストがある(装飾画像の場合は alt="")
  • フォーム入力欄に対応する <label> 要素がある
  • エラーメッセージがプログラムから入力欄に関連付けられている(aria-describedby)
  • カラーコントラストが WCAG AA を満たしている(通常のテキストは 4.5:1、大きなテキストは 3:1)
  • 色だけで伝えている情報がない
  • アニメーションで prefers-reduced-motion メディアクエリーに対応している
  • ページの見出し階層が正しい(h1 > h2 > h3、階層を飛ばさない)
  • 言語が宣言されている(<html lang="en">)
  • CI で axe-core のチェックを行う(ユーザーが自動 a11y テストを求めた場合のみ)
  • VoiceOver(macOS)または NVDA(Windows)を使った手動テストを実施している

関連リソース

  • スキル: performance-engineering(パフォーマンスと a11y の交差領域)、growth-engineering(アクセシブルなオンボーディング)
  • ルール: docs/reference/checklists/ui-visual-changes.md(視覚的な変更に関するアクセシビリティチェック)
  • ルール: docs/reference/stacks/react-typescript.md(React コンポーネントのパターン)

コメント