アクセシビリティ

支援技術に依存する障害のあるユーザーを含む、誰でも使えるコンポーネントの構築。

アクセシビリティ(a11y)は任意の機能ではなく、モダンなウェブコンポーネントにとっての根本的な要件です。すべてのコンポーネントは、視覚、運動、聴覚、認知に障害のある人々を含め、誰でも利用できるものでなければなりません。

このガイドは、コンポーネントを構築する際に従うべきアクセシビリティの原則とパターンの非網羅的な一覧です。包括的なガイドではありませんが、注意すべき問題の種類を把握するのに役立ちます。

Ultracite のような強力なアクセシビリティルールを持つリンターを使用している場合、これらの種類の問題は自動的に検出される可能性がありますが、原則を理解しておくことは依然として重要です。

コア原則

1. セマンティックHTMLを最優先

常に最も適切なHTML要素から始めてください。セマンティックHTMLは、カスタム実装では見落とされがちな組み込みのアクセシビリティ機能を提供します。

// ❌ Don't reinvent the wheel
<div onClick={handleClick} className="button">
  Click me
</div>

// ✅ Use semantic elements
<button onClick={handleClick}>
  Click me
</button>

セマンティック要素は、適切なロールのアナウンス、キーボード操作、フォーカス管理、およびフォーム参加を備えています。

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

すべてのインタラクティブ要素はキーボードで操作できる必要があります。ユーザーはキーボードのみで移動、起動、操作できるべきです。

// ✅ Complete keyboard support
function Menu() {
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch(e.key) {
      case 'ArrowDown':
        focusNextItem();
        break;
      case 'ArrowUp':
        focusPreviousItem();
        break;
      case 'Home':
        focusFirstItem();
        break;
      case 'End':
        focusLastItem();
        break;
      case 'Escape':
        closeMenu();
        break;
    }
  };

  return (
    <div role="menu" onKeyDown={handleKeyDown}>
      {/* menu items */}
    </div>
  );
}

3. スクリーンリーダーサポート

必要に応じてARIA属性を使用して、すべてのコンテンツと操作がスクリーンリーダーに適切に通知されるようにしてください。

// ✅ Proper ARIA labeling
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/" aria-current="page">Home</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>

// ✅ Dynamic content announcements
<div aria-live="polite" aria-atomic="true">
  {isLoading && <span>Loading results...</span>}
  {results && <span>{results.length} results found</span>}
</div>

4. ビジュアルアクセシビリティ

適切なコントラスト、フォーカスインジケーター、レスポンシブな文字サイズによって、視覚に障害のあるユーザーをサポートしてください。

/* ✅ Visible focus indicators */
button:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

/* ✅ Sufficient color contrast (4.5:1 for normal text, 3:1 for large text) */
.text {
  color: #333; /* Against white: 12.6:1 ratio */
  background: white;
}

/* ✅ Responsive text sizing */
.text {
  font-size: 1rem; /* Respects user preferences */
}

ARIAパターン

ARIAの理解

ARIA(Accessible Rich Internet Applications)は、支援技術に対して要素のセマンティック情報を提供します。ARIAはセマンティックHTMLを補強するために使用し、置き換えるために使わないでください。

従うべきいくつかのルールがあります:

  1. セマンティックHTMLを使えるならARIAを使わないこと
  2. 必要がない限りネイティブのセマンティクスを変更しないこと
  3. すべてのインタラクティブ要素はキーボードでアクセス可能であること
  4. フォーカス可能な要素を支援技術から隠さないこと
  5. すべてのインタラクティブ要素はアクセシブルな名前を持つこと

一般的なARIA属性

ロール

要素が何であるかを定義します:

// Widget roles
<div role="button" tabIndex={0} onClick={handleClick}>
  Custom Button
</div>

// Landmark roles
<div role="navigation" aria-label="Breadcrumb">
  {/* breadcrumb items */}
</div>

// Live region roles
<div role="alert">
  Error: Invalid email address
</div>

ステート

要素の現在の状態を記述します:

// Checked state
<div
  role="checkbox"
  aria-checked={isChecked}
  tabIndex={0}
>
  Accept terms
</div>

// Expanded state
<button
  aria-expanded={isOpen}
  aria-controls="panel-1"
>
  Toggle Panel
</button>
<div id="panel-1" hidden={!isOpen}>
  Panel content
</div>

// Selected state
<li
  role="option"
  aria-selected={isSelected}
>
  Option 1
</li>

プロパティ

追加情報を提供します:

// Labels and descriptions
<input
  aria-label="Search"
  aria-describedby="search-help"
  type="search"
/>
<span id="search-help">Press Enter to search</span>

// Relationships
<button aria-controls="modal-1">Open Modal</button>
<div id="modal-1" role="dialog">{/* modal content */}</div>

// Required and invalid
<input
  aria-required="true"
  aria-invalid={hasError}
  aria-errormessage="email-error"
/>
<span id="email-error">Please enter a valid email</span>

コンポーネントパターン

モーダル/ダイアログ

モーダルは慎重なフォーカス管理とキーボードトラップが必要です:

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Store current focus
      previousFocus.current = document.activeElement as HTMLElement;

      // Focus first focusable element in modal
      const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();

      // Prevent body scroll
      document.body.style.overflow = 'hidden';
    } else {
      // Restore focus
      previousFocus.current?.focus();
      document.body.style.overflow = '';
    }

    return () => {
      document.body.style.overflow = '';
    };
  }, [isOpen]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      onClose();
    }

    if (e.key === 'Tab') {
      // Trap focus within modal
      const focusables = modalRef.current?.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );

      if (focusables && focusables.length > 0) {
        const firstFocusable = focusables[0];
        const lastFocusable = focusables[focusables.length - 1];

        if (e.shiftKey && document.activeElement === firstFocusable) {
          e.preventDefault();
          lastFocusable.focus();
        } else if (!e.shiftKey && document.activeElement === lastFocusable) {
          e.preventDefault();
          firstFocusable.focus();
        }
      }
    }
  };

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
      onKeyDown={handleKeyDown}
      className="modal"
    >
      <button
        onClick={onClose}
        aria-label="Close dialog"
        className="close-button"
      >
        ×
      </button>
      {children}
    </div>
  );
}

ドロップダウンメニュー

ドロップダウンには適切なARIA属性とキーボードナビゲーションが必要です:

function DropdownMenu({ items }) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const menuRef = useRef<HTMLUListElement>(null);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch(e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setSelectedIndex(0);
        } else {
          setSelectedIndex(prev =>
            prev < items.length - 1 ? prev + 1 : 0
          );
        }
        break;

      case 'ArrowUp':
        e.preventDefault();
        if (isOpen) {
          setSelectedIndex(prev =>
            prev > 0 ? prev - 1 : items.length - 1
          );
        }
        break;

      case 'Enter':
      case ' ':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
        } else if (selectedIndex >= 0) {
          items[selectedIndex].onClick();
          setIsOpen(false);
        }
        break;

      case 'Escape':
        setIsOpen(false);
        setSelectedIndex(-1);
        break;
    }
  };

  return (
    <div className="dropdown">
      <button
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onKeyDown={handleKeyDown}
        onClick={() => setIsOpen(!isOpen)}
      >
        Menu
      </button>

      {isOpen && (
        <ul
          id="dropdown-menu"
          role="menu"
          ref={menuRef}
          onKeyDown={handleKeyDown}
        >
          {items.map((item, index) => (
            <li
              key={item.id}
              role="menuitem"
              tabIndex={-1}
              aria-selected={index === selectedIndex}
              onClick={() => {
                item.onClick();
                setIsOpen(false);
              }}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

タブ

タブインターフェースには特定のARIAパターンとキーボードナビゲーションが必要です:

function Tabs({ tabs, defaultTab = 0 }) {
  const [activeTab, setActiveTab] = useState(defaultTab);

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

    switch(e.key) {
      case 'ArrowLeft':
        newIndex = index > 0 ? index - 1 : tabs.length - 1;
        break;
      case 'ArrowRight':
        newIndex = index < tabs.length - 1 ? index + 1 : 0;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    setActiveTab(newIndex);

    // Focus the newly selected tab
    const tabElement = document.getElementById(`tab-${newIndex}`);
    tabElement?.focus();
  };

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

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

フォーム

フォームには明確なラベル、エラーメッセージ、およびバリデーションフィードバックが必要です:

function AccessibleForm() {
  const [errors, setErrors] = useState({});

  return (
    <form aria-label="Contact form">
      <div className="form-group">
        <label htmlFor="email">
          Email Address
          <span aria-label="required">*</span>
        </label>
        <input
          id="email"
          type="email"
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? "email-error" : "email-help"}
        />
        <span id="email-help" className="help-text">
          We'll never share your email
        </span>
        {errors.email && (
          <span id="email-error" role="alert" className="error">
            {errors.email}
          </span>
        )}
      </div>

      <fieldset>
        <legend>Notification Preferences</legend>
        <div>
          <input
            id="notify-email"
            type="checkbox"
            name="notifications"
            value="email"
          />
          <label htmlFor="notify-email">Email notifications</label>
        </div>
        <div>
          <input
            id="notify-sms"
            type="checkbox"
            name="notifications"
            value="sms"
          />
          <label htmlFor="notify-sms">SMS notifications</label>
        </div>
      </fieldset>

      <button type="submit">Submit</button>
    </form>
  );
}

フォーカス管理

フォーカスの表示

キーボードナビゲーション時のみフォーカスインジケーターを表示してください:

/* Remove default outline */
*:focus {
  outline: none;
}

/* Show outline only for keyboard focus */
*:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

/* Custom focus styles for specific components */
.button:focus-visible {
  box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}

フォーカストラップ

特定の領域内にフォーカスを維持します:

function useFocusTrap(ref: React.RefObject<HTMLElement>, isActive: boolean) {
  useEffect(() => {
    if (!isActive || !ref.current) return;

    const element = ref.current;
    const focusableSelector =
      'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex]:not([tabindex="-1"])';

    const focusableElements = element.querySelectorAll<HTMLElement>(focusableSelector);
    const firstFocusable = focusableElements[0];
    const lastFocusable = focusableElements[focusableElements.length - 1];

    const handleTabKey = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        if (document.activeElement === firstFocusable) {
          e.preventDefault();
          lastFocusable?.focus();
        }
      } else {
        if (document.activeElement === lastFocusable) {
          e.preventDefault();
          firstFocusable?.focus();
        }
      }
    };

    element.addEventListener('keydown', handleTabKey);
    firstFocusable?.focus();

    return () => {
      element.removeEventListener('keydown', handleTabKey);
    };
  }, [ref, isActive]);
}

フォーカスの復元

操作後に適切な要素にフォーカスを戻します:

function useRestoreFocus() {
  const previousFocus = useRef<HTMLElement | null>(null);

  const saveFocus = () => {
    previousFocus.current = document.activeElement as HTMLElement;
  };

  const restoreFocus = () => {
    previousFocus.current?.focus();
  };

  return { saveFocus, restoreFocus };
}

ライブリージョン

動的なコンテンツの変化をスクリーンリーダーに通知します:

ステータスメッセージ

// Polite announcement (waits for screen reader to finish)
<div role="status" aria-live="polite">
  {savedMessage && "Settings saved successfully"}
</div>

// Assertive announcement (interrupts screen reader)
<div role="alert" aria-live="assertive">
  {errorMessage && `Error: ${errorMessage}`}
</div>

// Loading states
<div aria-live="polite" aria-busy={isLoading}>
  {isLoading ? "Loading..." : `${items.length} items loaded`}
</div>

進捗インジケーター

function ProgressBar({ value, max = 100 }) {
  return (
    <div
      role="progressbar"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-label="Upload progress"
    >
      <div
        className="progress-fill"
        style={{ width: `${(value / max) * 100}%` }}
      />
      <span className="sr-only">
        {Math.round((value / max) * 100)}% complete
      </span>
    </div>
  );
}

色とコントラスト

コントラスト要件

WCAGガイドラインに従って色のコントラストを設定してください:

/* Normal text (< 18pt or < 14pt bold) */
.text {
  color: #595959; /* 7:1 ratio against white */
  background: white;
}

/* Large text (≥ 18pt or ≥ 14pt bold) */
.heading {
  color: #767676; /* 4.5:1 ratio against white */
  font-size: 1.5rem;
  font-weight: bold;
}

/* Non-text elements (icons, borders) */
.icon {
  color: #949494; /* 3:1 ratio against white */
}

色に依存しない表現

情報を色だけで伝えないでください:

// ❌ Color only
<span className="text-red-500">Error</span>

// ✅ Color with text/icon
<span className="text-red-500">
  <ErrorIcon aria-hidden="true" />
  <span>Error: Invalid input</span>
</span>

// ✅ Multiple indicators
<input
  className={hasError ? 'border-red-500' : 'border-gray-300'}
  aria-invalid={hasError}
  aria-describedby={hasError ? 'error-message' : undefined}
/>
{hasError && (
  <span id="error-message" className="text-red-500">
    <ErrorIcon aria-hidden="true" /> This field is required
  </span>
)}

モバイルアクセシビリティ

タッチターゲット

タッチターゲットが十分に大きいことを確認してください:

/* Minimum 44x44px for iOS, 48x48dp for Android */
.button {
  min-height: 44px;
  min-width: 44px;
  padding: 12px 16px;
}

/* Add invisible touch area for small icons */
.icon-button {
  position: relative;
  padding: 8px;
}

.icon-button::before {
  content: '';
  position: absolute;
  top: -8px;
  right: -8px;
  bottom: -8px;
  left: -8px;
}

ビューポートとズーム

ユーザーがズームできるようにしてください:

<!-- ✅ Allows zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1">

<!-- ❌ Prevents zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

よくある落とし穴

プレースホルダをラベルとして使わない

プレースホルダを唯一のラベルとして使わないでください:

// ❌ Placeholder disappears when typing
<input placeholder="Email address" />

// ✅ Persistent label
<label>
  Email address
  <input type="email" />
</label>

// ✅ Floating label pattern
<div className="form-field">
  <input id="email" placeholder=" " />
  <label htmlFor="email">Email address</label>
</div>

空のボタン

アイコンボタンには常にアクセシブルなテキストを提供してください:

// ❌ No accessible name
<button onClick={handleDelete}>
  <TrashIcon />
</button>

// ✅ Screen reader text
<button onClick={handleDelete} aria-label="Delete item">
  <TrashIcon aria-hidden="true" />
</button>

// ✅ Visually hidden text
<button onClick={handleDelete}>
  <TrashIcon aria-hidden="true" />
  <span className="sr-only">Delete item</span>
</button>

無効化されたフォーム要素

無効化された要素はフォーカスできないため、ユーザーを混乱させる可能性があります:

// ❌ User can't understand why button is disabled
<button disabled={!isValid}>
  Submit
</button>

// ✅ Use aria-disabled and explain
<button
  aria-disabled={!isValid}
  aria-describedby="submit-help"
  onClick={isValid ? handleSubmit : undefined}
  className={!isValid ? 'opacity-50 cursor-not-allowed' : ''}
>
  Submit
</button>
<span id="submit-help">
  {!isValid && 'Please fill in all required fields'}
</span>