Web Accessibility: WCAG 2.1 AA Compliance Guide

Build WCAG 2.1 AA compliant web apps with this practical guide. Covers semantic HTML, ARIA, keyboard navigation, color contrast, screen readers, and automated testing tools.

E
ECOSIRE Research and Development Team
|2026年3月19日6 分で読める1.4k 語数|

Compliance & Regulationシリーズの一部

完全ガイドを読む

Web アクセシビリティ: WCAG 2.1 AA 準拠ガイド

アクセシビリティは、発売後に追加する機能ではありません。これは、パフォーマンスやセキュリティと同じ、基本的な品質属性です。 WCAG 2.1 AA への準拠は現在、EU (欧州アクセシビリティ法、2025 年 6 月施行)、米国 (ADA Title III 判例法)、およびその他の多くの法域で法的に義務付けられています。コンプライアンスを超えて、アクセシブルなインターフェースはコンバージョン率を向上させ、検索で上位にランクされ、世界中で推定 13 億人の障害を持つ人々にサービスを提供しています。

このガイドは実践的な実装マニュアルであり、チェックリストではありません。 WCAG の 4 つの原則、最も影響力のあるテクニック、体系的にテストする方法、アクセシビリティを React/Next.js 開発ワークフローに統合して修正を維持する方法を学びます。

重要なポイント

  • WCAG 2.1 AA では、POUR の 4 つの原則 (知覚可能、操作可能、理解可能、堅牢) をすべて必要とします。
  • セマンティック HTML から始めます — ARIA が追加される前に、アクセシビリティの 70% が無料で提供されます
  • 最小カラーコントラスト比: 通常のテキストの場合は 4.5:1、大きなテキストの場合は 3:1 (18pt/14pt の太字)
  • すべてのインタラクティブな要素は、目に見えるフォーカス インジケーターを使用してキーボードでフォーカス可能である必要があります。
  • スクリーン リーダーはアクセシビリティ ツリーに基づいてアナウンスします — NVDA (Windows) および VoiceOver (Mac) でテストします
  • ARIA は最後の手段です。これは支援技術が DOM を解釈する方法を変更するだけであり、動作は変更しません
  • CI パイプラインで axe-core を使用して自動化します。手動テストは自動化で見逃されるものを発見します
  • アクセシビリティに関する声明を文書化し、ユーザーが問題を報告するためのフィードバック メカニズムを提供する

注ぐ 4 つの原則

WCAG 2.1 は 4 つの原則に基づいて構成されています。すべての成功基準は、そのいずれかに属します。

知覚可能: 情報は、ユーザーが知覚できる方法で表示できる必要があります。これには、画像のテキスト代替、ビデオのキャプション、十分な色のコントラスト、および意味を伝えるために色のみに依存しないコンテンツが含まれます。

操作可能: すべての機能はキーボード経由で操作可能である必要があり、対話するのに十分な時間があり、発作を引き起こすコンテンツがなく、ナビゲート可能な構造 (スキップ リンク、ページ タイトル、フォーカス順序) が必要です。

理解可能: コンテンツは読みやすく、予測可能である必要があります。言語を識別する必要があり、エラー メッセージは説明的である必要があり、フォームには明確なラベルと検証フィードバックが含まれている必要があります。

堅牢: コンテンツは現在および将来の支援技術で解釈可能でなければなりません。これは、有効な HTML、適切な ARIA の使用法、およびフォーカスを必要とせずにアナウンスされるステータス メッセージを意味します。


セマンティック HTML ファースト

セマンティック HTML は、アクセシビリティへの投資の中で最大の効果を発揮します。ネイティブ HTML 要素には、アクセシビリティの役割、状態、キーボード動作が組み込まれており、ARIA は必要ありません。

// BAD: Generic divs with no semantics
<div class="button" onclick="submit()">Submit</div>
<div class="nav">
  <div class="link" onclick="navigate('/home')">Home</div>
</div>

// GOOD: Native semantics, free keyboard and screen reader support
<button type="submit" onClick={submit}>Submit</button>
<nav aria-label="Main navigation">
  <a href="/home">Home</a>
</nav>

ランドマーク領域は、スクリーン リーダー ユーザーがセクション間をジャンプしてすばやく移動できるようにします。

// Every page should have these landmarks
<header>        {/* banner landmark */}
  <nav aria-label="Main">...</nav>
</header>
<main>           {/* main landmark */}
  <h1>Page Title</h1>
  <article>...</article>
  <aside aria-label="Related content">...</aside>
</main>
<footer>         {/* contentinfo landmark */}
  <nav aria-label="Footer">...</nav>
</footer>

見出し階層は論理的であり、切れ目がない必要があります。

// BAD: Skipped heading levels
<h1>Page Title</h1>
<h3>Section</h3>  {/* Skipped h2! */}

// GOOD: Sequential hierarchy
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>

カラーコントラスト

WCAG 2.1 AA には以下が必要です。

  • 通常のテキストの 4.5:1 コントラスト比 (18pt 未満 / 14pt 太字)
  • 3:1 大きなテキストのコントラスト比 (18pt+ / 14pt+ 太字)
  • 3:1 UI コンポーネントおよびグラフィック オブジェクト (ボタン、アイコン、入力枠)
// Tailwind color contrast examples
// FAIL: gray-400 on white (#9ca3af on #fff = 2.8:1)
<p className="text-gray-400">This fails AA</p>

// PASS: gray-700 on white (#374151 on #fff = 10.7:1)
<p className="text-gray-700">This passes AA</p>

// For dark mode, test both themes separately
<p className="text-gray-700 dark:text-gray-300">
  gray-700 on white (10.7:1) / gray-300 on gray-900 (9.2:1)
</p>

開発中に WebAIM コントラスト チェッカーまたはブラウザの DevTools コントラスト ツールを使用します。これを Storybook またはデザイン トークン システムに追加して、回帰を検出します。

// contrast-checker.ts
import { getContrast } from 'polished';

function assertContrast(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA') {
  const ratio = getContrast(fg, bg);
  const required = level === 'AA' ? 4.5 : 7;
  if (ratio < required) {
    throw new Error(
      `Contrast ratio ${ratio.toFixed(2)}:1 fails WCAG ${level} (requires ${required}:1)`
    );
  }
}

キーボード ナビゲーション

すべてのインタラクティブな要素 (リンク、ボタン、フォーム フィールド、カスタム ウィジェット) は、キーボードからアクセス可能であり、操作可能である必要があります。

集中管理

// Skip link: first element on every page
// Allows keyboard users to jump past navigation
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
                 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white
                 focus:rounded focus:ring-2 focus:ring-white"
    >
      Skip to main content
    </a>
  );
}

// Main content target
<main id="main-content" tabIndex={-1}>
  {/* tabIndex={-1} allows programmatic focus without appearing in tab order */}

モーダルでのフォーカス トラッピング

ダイアログが開いたら、フォーカスをダイアログ内に閉じ込める必要があります。閉じると、フォーカスはトリガーに戻ります。

// focus-trap.tsx using @radix-ui/react-focus-trap (used internally by shadcn Dialog)
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useRef } from 'react';

export function AccessibleModal({ trigger, children, title }: Props) {
  const triggerRef = useRef<HTMLButtonElement>(null);

  return (
    <Dialog>
      <DialogTrigger ref={triggerRef} asChild>
        <button>Open</button>
      </DialogTrigger>
      <DialogContent
        // shadcn Dialog handles focus trap and returns focus to trigger on close
        aria-describedby="dialog-description"
      >
        <DialogTitle>{title}</DialogTitle>
        <p id="dialog-description" className="sr-only">
          {/* Screen reader description of dialog purpose */}
        </p>
        {children}
      </DialogContent>
    </Dialog>
  );
}

目に見えるフォーカスインジケーター

WCAG 2.1 SC 2.4.11 (WCAG 2.2 の AA) では、最小 2 ピクセルのフォーカス アウトラインが必要です。代替品を使用せずにフォーカスを抑制しないでください。

/* globals.css */
/* NEVER do this: */
:focus { outline: none; }

/* DO this: custom, visible focus ring */
:focus-visible {
  outline: 2px solid hsl(var(--ring));
  outline-offset: 2px;
  border-radius: 4px;
}

/* Remove for mouse users (only show for keyboard) */
:focus:not(:focus-visible) {
  outline: none;
}

ARIA: いつ、どのように使用するか

ARIA (Accessible Rich Internet Applications) 属性は、支援技術による DOM の解釈方法を変更します。 ARIA の最初のルール: ユースケースにネイティブ HTML 要素が存在する場合は、それを使用しない

ARIA ラベル

// Icon-only button — screen reader has nothing to announce without aria-label
<button aria-label="Close dialog">
  <X className="h-4 w-4" aria-hidden="true" />
</button>

// Form field with visible label — use htmlFor, not aria-label
<label htmlFor="email">Email address</label>
<input id="email" type="email" />

// Input with visible description
<input
  id="password"
  type="password"
  aria-describedby="password-requirements"
/>
<p id="password-requirements">Must be at least 12 characters.</p>

ARIA ライブ リージョン

フォーカスを移動せずに動的なコンテンツの変更を通知します。

// Status messages (search results count, form submission status)
function SearchResults({ count, loading }: Props) {
  return (
    <>
      {/* aria-live="polite" waits for user to finish current action */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {loading ? 'Loading results...' : `${count} results found`}
      </div>
      {/* Visual result count (not for screen readers — aria-hidden) */}
      <span aria-hidden="true">{count} results</span>
    </>
  );
}

// Error messages (aria-live="assertive" interrupts immediately)
function FormError({ error }: { error: string | null }) {
  return (
    <div
      role="alert"
      aria-live="assertive"
      className={cn('text-red-500 text-sm', !error && 'hidden')}
    >
      {error}
    </div>
  );
}

カスタム ウィジェット用の ARIA

カスタム ウィジェット (タブ パネル、ツリー ビュー、コンボボックス) を構築する必要がある場合は、ARIA オーサリング プラクティス ガイド (APG) のパターンに従ってください。

// Accessible tabs (ARIA tab pattern)
export function Tabs({ items }: { items: Tab[] }) {
  const [active, setActive] = useState(0);

  return (
    <div>
      <div role="tablist" aria-label="Content tabs">
        {items.map((item, i) => (
          <button
            key={item.id}
            role="tab"
            aria-selected={active === i}
            aria-controls={`panel-${item.id}`}
            id={`tab-${item.id}`}
            tabIndex={active === i ? 0 : -1} // Roving tabindex
            onClick={() => setActive(i)}
            onKeyDown={(e) => {
              if (e.key === 'ArrowRight') setActive((active + 1) % items.length);
              if (e.key === 'ArrowLeft') setActive((active - 1 + items.length) % items.length);
            }}
          >
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item, i) => (
        <div
          key={item.id}
          role="tabpanel"
          id={`panel-${item.id}`}
          aria-labelledby={`tab-${item.id}`}
          hidden={active !== i}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}

フォームとエラー処理

アクセシブルなフォームは、認知障害および運動障害を持つユーザーにとって最も大きな影響を与える改善の 1 つです。

// Accessible form field with error state
function TextField({
  id,
  label,
  error,
  required,
  hint,
  ...props
}: TextFieldProps) {
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;

  return (
    <div>
      <label htmlFor={id} className="font-medium">
        {label}
        {required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
        {required && <span className="sr-only">(required)</span>}
      </label>

      {hint && (
        <p id={hintId} className="text-sm text-gray-500 mt-1">
          {hint}
        </p>
      )}

      <input
        id={id}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={describedBy}
        className={cn('input', error && 'border-red-500')}
        {...props}
      />

      {error && (
        <p id={errorId} className="text-sm text-red-500 mt-1" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

画像とメディア

// Informative image
<img src="/chart.png" alt="Bar chart showing 40% revenue growth in Q4 2025" />

// Decorative image — empty alt hides it from screen readers
<img src="/divider.png" alt="" role="presentation" />

// Complex image — use aria-describedby for long descriptions
<figure>
  <img
    src="/architecture.png"
    alt="System architecture diagram"
    aria-describedby="arch-desc"
  />
  <figcaption id="arch-desc">
    The diagram shows three tiers: frontend Next.js on port 3000,
    NestJS API on port 3001, and PostgreSQL database on port 5433.
    Redis sits between the API and database layers.
  </figcaption>
</figure>

// SVG icons used as decoration
<svg aria-hidden="true" focusable="false">
  <use href="#icon-search" />
</svg>

axe-core による自動テスト

pnpm add -D @axe-core/playwright axe-core
// tests/a11y/homepage.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Homepage accessibility', () => {
  test('should have no WCAG 2.1 AA violations', async ({ page }) => {
    await page.goto('/');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .exclude('#third-party-widget') // Exclude known external violations
      .analyze();

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

  test('should be keyboard navigable', async ({ page }) => {
    await page.goto('/');
    // Tab through interactive elements and verify focus is visible
    await page.keyboard.press('Tab');
    const focusedElement = await page.evaluate(
      () => document.activeElement?.getAttribute('href')
    );
    expect(focusedElement).toBe('#main-content'); // Skip link
  });
});

CI パイプラインに追加します。

# .github/workflows/ci.yml
- name: Run accessibility tests
  run: cd apps/web && npx playwright test tests/a11y --reporter=html
- uses: actions/upload-artifact@v4
  with:
    name: a11y-report
    path: apps/web/playwright-report/

よくある質問

WCAG 2.1 A、AA、AAA の違いは何ですか?

レベル A は最低です。レベル A に失敗すると、一部のユーザーは基本的な方法でコンテンツにアクセスできなくなります。レベル AA は、ほとんどの管轄区域における法的基準であり、最も広範なユーザーのニーズを対象としています。レベル AAA は意欲的なものですが、一部の基準はすべてのコンテンツ タイプで満たされるわけではありません。 AA コンプライアンスをベースラインとして目標にし、実際的な場合は AAA を目指します。

shadcn/ui などのコンポーネント ライブラリを使用すると、アプリにアクセスできるようになりますか?

shadcn/ui は、設計によりアクセス可能な Radix UI プリミティブに基づいて構築されており、正しい ARIA ロール、キーボード ナビゲーション、フォーカス管理が含まれています。ただし、意味のあるラベルを追加し、エラー状態をアクセスしやすく処理し、カスタム テーマとの十分な色のコントラストを確保し、実際の支援テクノロジを使用してテストする必要があります。コンポーネント ライブラリは負担を軽減しますが、アクセシビリティ テストの必要性がなくなるわけではありません。

スクリーン リーダーを使用してテストするにはどうすればよいですか?

Windows では、Firefox または Chrome で NVDA (無料) を使用します。 macOS では、Safari で VoiceOver (組み込み、Cmd+F5) を使用します。モバイルでは、TalkBack (Android) または VoiceOver (iOS) を使用します。主要なユーザー ジャーニーをテストします。フォームの入力、モーダル インタラクション、ランドマークによるナビゲーション、動的コンテンツの読み取りなどです。スクリーン リーダーのテストでは、自動化ツールが見逃すアナウンス、読み上げる順序、フォーカス動作を捕捉します。

移動タブインデックス パターンとは何ですか?

Roving tabindex は、複合ウィジェット (タブ リスト、ツールバー、ラジオ グループ、ツリー ビュー) のキーボード パターンです。一度に tabIndex={0} を持つ項目はグループ内で 1 つだけ、つまりアクティブな項目だけです。他のすべては tabIndex={-1} を取得します。矢印キーはグループ内でフォーカスを移動し、どの項目の tabIndex 0 が更新されるかを更新します。これにより、ユーザーはグループ内のすべての項目を Tab キーで移動できなくなります。ユーザーは Tab キーでグループに入り、矢印キーで移動し、Tab キーで終了します。

AJAX 経由でロードされた動的コンテンツのアクセシビリティを処理するにはどうすればよいですか?

ステータス更新 (検索結果数、保存確認) には aria-live 領域を使用します。ページ全体のセクションを置換する場合は、ロード後に新しいコンテンツの見出しまたはコンテナにフォーカスを移動します。状態をロードするには、更新中の領域で aria-busy="true" を使用し、完了を通知するために aria-live="polite" 領域を使用します。常にスクリーン リーダーを使用してテストし、アナウンスが明確かつタイムリーであることを確認してください。


次のステップ

Web アクセシビリティは、1 回限りの監査ではなく、継続的に実施されます。まずセマンティック HTML と色のコントラストを修正し、次に複雑なウィジェット用にキーボード ナビゲーションと ARIA を重ね、CI パイプラインで WCAG 検証を自動化して回帰をキャッチします。

ECOSIRE は、すべてのプロジェクトのベースライン標準として、WCAG 2.1 AA 準拠の Web アプリケーションを構築します。アクセシビリティ監査が必要な場合、または準拠したものを最初から構築したい場合は、フロントエンド エンジニアリング サービスを探索してください。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット