React Ariaとは?アクセシブルなUIコンポーネントの作り方

React Ariaを使用してアクセシブルなUIコンポーネントを作成する方法を詳しく解説。WAI-ARIAガイドライン準拠、フックベースの実装、実践的なコンポーネント例を紹介します。

Learning Next 運営
75 分で読めます

React Ariaとは?アクセシブルなUIコンポーネントの作り方

みなさん、Webアプリケーションを作る時に、アクセシビリティのことを考えていますか?

「すべてのユーザーにやさしいUIを作りたい」って思ったことはありませんか? でも、「何から始めればいいんだろう」と悩んでいる方も多いはずです。

今回は、React Ariaを使って、誰でも使いやすいUIコンポーネントを作る方法を説明します。 この記事を読めば、プロレベルのアクセシブルなコンポーネントが作れるようになりますよ!

React Ariaとは

アクセシビリティって何?

React Ariaは、アクセシブルなUIコンポーネントを作るためのライブラリです。

簡単に言うと、障害のある方でも使いやすいWebサイトを作るための道具です。

なぜアクセシビリティが重要なのか

まず、よくある問題のあるボタンを見てみましょう。

// アクセシビリティを考慮していないボタン
const BadButton = ({ onClick, children }) => {
  return (
    <div onClick={onClick} style={{ cursor: 'pointer' }}>
      {children}
    </div>
  );
};

このボタンには、いくつかの問題があります。 キーボードで操作できないですし、スクリーンリーダーがボタンだと認識してくれません。

でも、React Ariaを使えば、これらの問題を簡単に解決できます。

// React Ariaを使用したアクセシブルなボタン
import { useButton } from '@react-aria/button';
import { useRef } from 'react';

const AccessibleButton = ({ onPress, children, isDisabled }) => {
  const ref = useRef();
  const { buttonProps } = useButton({ onPress, isDisabled }, ref);
  
  return (
    <button
      {...buttonProps}
      ref={ref}
      style={{
        backgroundColor: isDisabled ? '#ccc' : '#007bff',
        color: 'white',
        border: 'none',
        padding: '8px 16px',
        borderRadius: '4px',
        cursor: isDisabled ? 'not-allowed' : 'pointer'
      }}
    >
      {children}
    </button>
  );
};

このコードを見ると、useButtonフックを使うだけで、アクセシビリティの機能が自動的に追加されます。 キーボード操作やスクリーンリーダー対応が、何も考えなくても動作するんです。

とても便利ですよね!

WAI-ARIAガイドラインって何?

WAI-ARIAは、Webアクセシビリティの国際標準です。

React Ariaが提供する機能の一例をご紹介します。

// React Aria が自動的に提供するアクセシビリティ機能
const AccessibilityFeatures = {
  // キーボードナビゲーション
  keyboardSupport: {
    'Tab': 'フォーカス移動',
    'Enter/Space': 'ボタンの実行',
    'Arrow Keys': 'リスト内移動',
    'Escape': 'ダイアログを閉じる'
  },
  
  // ARIAラベルと説明
  ariaAttributes: {
    'aria-label': 'コンポーネントの名前',
    'aria-describedby': '追加の説明',
    'aria-expanded': '展開状態',
    'aria-selected': '選択状態'
  },
  
  // フォーカス管理
  focusManagement: {
    'focus trapping': 'モーダル内でのフォーカス制御',
    'focus restoration': '元の位置への復帰',
    'focus visible': '視覚的フォーカス表示'
  },
  
  // スクリーンリーダー対応
  screenReaderSupport: {
    'live regions': '動的コンテンツの通知',
    'semantic markup': '意味のあるHTMLタグ',
    'role attributes': '要素の役割を明示'
  }
};

これらの機能が、React Ariaを使うだけで自動的に組み込まれます。 手動で設定する必要はありません!

React Ariaの特徴

React Ariaの便利な特徴を見てみましょう。

フックベースのアーキテクチャ

React Ariaは、React Hooksを使って作られています。

// React Aria の基本的な使用パターン
import { useButton } from '@react-aria/button';
import { useToggleState } from '@react-stately/toggle';
import { useToggleButton } from '@react-aria/button';

const ToggleButton = ({ children, defaultSelected }) => {
  // 状態管理
  const state = useToggleState({ defaultSelected });
  
  // アクセシビリティ機能
  const ref = useRef();
  const { buttonProps } = useToggleButton(
    { 
      isSelected: state.isSelected,
      onChange: state.toggle
    }, 
    state, 
    ref
  );
  
  return (
    <button
      {...buttonProps}
      ref={ref}
      style={{
        backgroundColor: state.isSelected ? '#28a745' : '#6c757d',
        color: 'white',
        border: 'none',
        padding: '8px 16px',
        borderRadius: '4px'
      }}
    >
      {children}
    </button>
  );
};

この例では、useToggleStateで状態を管理し、useToggleButtonでアクセシビリティ機能を追加しています。

ロジックとUIが分離されているので、コードが整理しやすいです。

プリミティブコンポーネント

React Ariaには、たくさんの基本的なコンポーネントが用意されています。

// React Aria が提供するコンポーネント
const PrimitiveComponents = {
  // 基本的なインタラクション
  button: 'useButton',
  checkbox: 'useCheckbox',
  radio: 'useRadio',
  textField: 'useTextField',
  
  // 複雑なコンポーネント
  select: 'useSelect',
  combobox: 'useComboBox',
  listbox: 'useListBox',
  menu: 'useMenu',
  
  // レイアウトとナビゲーション
  tabs: 'useTabs',
  breadcrumbs: 'useBreadcrumbs',
  pagination: 'usePagination',
  
  // データ表示
  table: 'useTable',
  grid: 'useGrid',
  tree: 'useTree',
  
  // フィードバック
  tooltip: 'useTooltip',
  dialog: 'useDialog',
  popover: 'usePopover'
};

これらのコンポーネントを組み合わせて、複雑なUIを作ることができます。

他のアクセシビリティライブラリとの比較

React Ariaと他のライブラリの違いを理解してみましょう。

ライブラリの比較表

それぞれのライブラリには、異なる特徴があります。

// React Aria の特徴
const ReactAriaFeatures = {
  philosophy: 'ヘッドレス(スタイルなし)',
  flexibility: '完全なカスタマイズ可能',
  bundle: '必要な部分のみインポート',
  standards: 'WAI-ARIA準拠',
  hooks: 'React Hooksベース'
};

// 他のライブラリとの比較
const LibraryComparison = {
  'React Aria': {
    pros: ['完全カスタマイズ', 'WAI-ARIA準拠', '軽量'],
    cons: ['スタイリング必要', '学習コスト'],
    useCase: 'デザインシステム構築'
  },
  
  'Chakra UI': {
    pros: ['即座に使用可能', 'テーマシステム'],
    cons: ['カスタマイズ制限', 'バンドルサイズ'],
    useCase: '迅速なプロトタイピング'
  },
  
  'Material-UI': {
    pros: ['豊富なコンポーネント', 'デザインシステム'],
    cons: ['Material Design固定', '重い'],
    useCase: 'Material Design採用'
  }
};

用途に応じて、適切なライブラリを選ぶことが大切です。

React Ariaは、自由度の高いデザインを実現したい場合に最適です。

基本的な使用方法

インストールと設定

React Ariaを使い始める準備をしましょう。

パッケージのインストール

まず、必要なパッケージをインストールします。

# React Aria のコアパッケージ
npm install @react-aria/button @react-aria/textfield @react-aria/select
npm install @react-stately/toggle @react-stately/collections

# または必要なパッケージのみ
npm install @react-aria/interactions @react-aria/focus
npm install @react-aria/utils @react-aria/ssr

# 型定義(TypeScript使用時)
npm install @types/react @types/react-dom

React Ariaの良いところは、必要な部分だけをインストールできることです。 プロジェクトのバンドルサイズを小さく保てます。

基本的なプロバイダー設定

SSRアプリケーションでは、プロバイダーの設定が必要です。

// App.js - SSRアプリケーションの場合
import { SSRProvider } from '@react-aria/ssr';
import { I18nProvider } from '@react-aria/i18n';

const App = () => {
  return (
    <SSRProvider>
      <I18nProvider locale="ja-JP">
        <MainContent />
      </I18nProvider>
    </SSRProvider>
  );
};

const MainContent = () => {
  return (
    <div>
      <h1>アクセシブルアプリケーション</h1>
      <AccessibleForm />
      <AccessibleNavigation />
    </div>
  );
};

export default App;

SSRProviderは、サーバーサイドレンダリングで必要です。 I18nProviderは、多言語対応に使用します。

通常のクライアントサイドアプリケーションでは、これらの設定は不要です。

基本的なコンポーネントの作成

React Ariaを使って、基本的なコンポーネントを作ってみましょう。

アクセシブルなボタン

まず、完全にアクセシブルなボタンを作成します。

import { useButton } from '@react-aria/button';
import { useFocusRing } from '@react-aria/focus';
import { mergeProps } from '@react-aria/utils';
import { useRef } from 'react';

const Button = ({ 
  onPress, 
  children, 
  isDisabled = false, 
  variant = 'primary',
  size = 'medium'
}) => {
  const ref = useRef();
  
  // ボタンの基本機能
  const { buttonProps, isPressed } = useButton({ 
    onPress, 
    isDisabled 
  }, ref);
  
  // フォーカスリング(キーボードフォーカス時の視覚的表示)
  const { focusProps, isFocusVisible } = useFocusRing();
  
  // スタイルの計算
  const getButtonStyles = () => {
    const baseStyles = {
      padding: size === 'small' ? '4px 8px' : size === 'large' ? '12px 24px' : '8px 16px',
      fontSize: size === 'small' ? '14px' : size === 'large' ? '18px' : '16px',
      border: 'none',
      borderRadius: '4px',
      cursor: isDisabled ? 'not-allowed' : 'pointer',
      opacity: isDisabled ? 0.6 : 1,
      transition: 'all 0.2s ease',
      outline: isFocusVisible ? '2px solid #007bff' : 'none',
      outlineOffset: '2px'
    };
    
    const variantStyles = {
      primary: {
        backgroundColor: isPressed ? '#0056b3' : '#007bff',
        color: 'white'
      },
      secondary: {
        backgroundColor: isPressed ? '#5a6268' : '#6c757d',
        color: 'white'
      },
      outline: {
        backgroundColor: isPressed ? '#e2e6ea' : 'transparent',
        color: '#007bff',
        border: '1px solid #007bff'
      }
    };
    
    return { ...baseStyles, ...variantStyles[variant] };
  };
  
  return (
    <button
      {...mergeProps(buttonProps, focusProps)}
      ref={ref}
      style={getButtonStyles()}
    >
      {children}
    </button>
  );
};

このコードを見ると、useButtonでボタンの基本機能、useFocusRingでフォーカス表示を実装しています。

mergePropsを使って、複数のプロパティを安全に結合できます。

使用例も見てみましょう。

// 使用例
const ButtonExample = () => {
  return (
    <div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
      <Button onPress={() => alert('Primary clicked!')}>
        Primary Button
      </Button>
      
      <Button variant="secondary" onPress={() => alert('Secondary clicked!')}>
        Secondary Button
      </Button>
      
      <Button variant="outline" size="large" onPress={() => alert('Large clicked!')}>
        Large Outline Button
      </Button>
      
      <Button isDisabled onPress={() => alert('This should not fire')}>
        Disabled Button
      </Button>
    </div>
  );
};

様々なスタイルとサイズのボタンを作ることができます。

アクセシブルなテキストフィールド

次に、フォーム入力に必要なテキストフィールドを実装します。

import { useTextField } from '@react-aria/textfield';
import { useFocusRing } from '@react-aria/focus';
import { mergeProps } from '@react-aria/utils';
import { useRef, useState } from 'react';

const TextField = ({ 
  label, 
  description, 
  errorMessage, 
  isRequired = false,
  isDisabled = false,
  type = 'text',
  value,
  onChange,
  placeholder,
  ...props 
}) => {
  const ref = useRef();
  const [inputValue, setInputValue] = useState(value || '');
  
  // テキストフィールドの基本機能
  const { 
    labelProps, 
    inputProps, 
    descriptionProps, 
    errorMessageProps 
  } = useTextField({
    label,
    description,
    errorMessage,
    isRequired,
    isDisabled,
    type,
    value: inputValue,
    onChange: (value) => {
      setInputValue(value);
      onChange?.(value);
    },
    ...props
  }, ref);
  
  // フォーカスリング
  const { focusProps, isFocusVisible } = useFocusRing();
  
  const hasError = !!errorMessage;
  
  const getInputStyles = () => ({
    width: '100%',
    padding: '8px 12px',
    border: `2px solid ${hasError ? '#dc3545' : '#ced4da'}`,
    borderRadius: '4px',
    fontSize: '16px',
    backgroundColor: isDisabled ? '#f8f9fa' : 'white',
    outline: isFocusVisible ? '2px solid #007bff' : 'none',
    outlineOffset: '2px',
    transition: 'border-color 0.2s ease'
  });
  
  return (
    <div style={{ marginBottom: '16px' }}>
      {/* ラベル */}
      <label {...labelProps} style={{ 
        display: 'block', 
        marginBottom: '4px',
        fontWeight: '600',
        color: hasError ? '#dc3545' : '#333'
      }}>
        {label}
        {isRequired && <span style={{ color: '#dc3545' }}> *</span>}
      </label>
      
      {/* 説明文 */}
      {description && (
        <div 
          {...descriptionProps} 
          style={{ 
            fontSize: '14px', 
            color: '#6c757d', 
            marginBottom: '4px' 
          }}
        >
          {description}
        </div>
      )}
      
      {/* 入力フィールド */}
      <input
        {...mergeProps(inputProps, focusProps)}
        ref={ref}
        placeholder={placeholder}
        style={getInputStyles()}
      />
      
      {/* エラーメッセージ */}
      {hasError && (
        <div 
          {...errorMessageProps} 
          style={{ 
            fontSize: '14px', 
            color: '#dc3545', 
            marginTop: '4px' 
          }}
        >
          {errorMessage}
        </div>
      )}
    </div>
  );
};

このテキストフィールドには、ラベル、説明文、エラーメッセージが適切に関連付けられています。

スクリーンリーダーが、これらの情報を正しく読み上げてくれます。

実際の使用例を見てみましょう。

// 使用例
const TextFieldExample = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});
  
  const validateEmail = (value) => {
    if (!value) return 'メールアドレスは必須です';
    if (!/\S+@\S+\.\S+/.test(value)) return 'メールアドレスの形式が正しくありません';
    return '';
  };
  
  const validatePassword = (value) => {
    if (!value) return 'パスワードは必須です';
    if (value.length < 8) return 'パスワードは8文字以上で入力してください';
    return '';
  };
  
  const handleEmailChange = (value) => {
    setEmail(value);
    setErrors(prev => ({ ...prev, email: validateEmail(value) }));
  };
  
  const handlePasswordChange = (value) => {
    setPassword(value);
    setErrors(prev => ({ ...prev, password: validatePassword(value) }));
  };
  
  return (
    <form style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
      <h2>ログインフォーム</h2>
      
      <TextField
        label="メールアドレス"
        type="email"
        isRequired
        value={email}
        onChange={handleEmailChange}
        placeholder="example@email.com"
        description="ログインに使用するメールアドレスを入力してください"
        errorMessage={errors.email}
      />
      
      <TextField
        label="パスワード"
        type="password"
        isRequired
        value={password}
        onChange={handlePasswordChange}
        placeholder="8文字以上のパスワード"
        description="安全なパスワードを設定してください"
        errorMessage={errors.password}
      />
      
      <Button 
        onPress={() => console.log('Login:', { email, password })}
        isDisabled={!email || !password || errors.email || errors.password}
        style={{ width: '100%', marginTop: '16px' }}
      >
        ログイン
      </Button>
    </form>
  );
};

バリデーション機能も含めた、完全なログインフォームが作成できました。

エラーメッセージも適切に表示されます。

複雑なコンポーネントの実装

アクセシブルなドロップダウン

もう少し複雑なUIパターンを実装してみましょう。

セレクトコンポーネント

ドロップダウンメニューは、アクセシビリティの実装が特に重要です。

import { useSelect } from '@react-aria/select';
import { useListBox, useOption } from '@react-aria/listbox';
import { useButton } from '@react-aria/button';
import { useFocusRing } from '@react-aria/focus';
import { useSelectState } from '@react-stately/select';
import { mergeProps } from '@react-aria/utils';
import { useRef } from 'react';

const Select = ({ 
  label, 
  children, 
  isDisabled,
  isRequired,
  errorMessage,
  description,
  ...props 
}) => {
  // 状態管理
  const state = useSelectState(props);
  
  // 参照
  const ref = useRef();
  const {
    labelProps,
    triggerProps,
    valueProps,
    menuProps,
    descriptionProps,
    errorMessageProps
  } = useSelect(props, state, ref);
  
  const { focusProps, isFocusVisible } = useFocusRing();
  
  const hasError = !!errorMessage;
  
  const getTriggerStyles = () => ({
    width: '100%',
    padding: '8px 32px 8px 12px',
    border: `2px solid ${hasError ? '#dc3545' : '#ced4da'}`,
    borderRadius: '4px',
    backgroundColor: isDisabled ? '#f8f9fa' : 'white',
    cursor: isDisabled ? 'not-allowed' : 'pointer',
    outline: isFocusVisible ? '2px solid #007bff' : 'none',
    outlineOffset: '2px',
    position: 'relative',
    textAlign: 'left'
  });
  
  return (
    <div style={{ marginBottom: '16px', position: 'relative' }}>
      {/* ラベル */}
      <label {...labelProps} style={{
        display: 'block',
        marginBottom: '4px',
        fontWeight: '600',
        color: hasError ? '#dc3545' : '#333'
      }}>
        {label}
        {isRequired && <span style={{ color: '#dc3545' }}> *</span>}
      </label>
      
      {/* 説明文 */}
      {description && (
        <div {...descriptionProps} style={{
          fontSize: '14px',
          color: '#6c757d',
          marginBottom: '4px'
        }}>
          {description}
        </div>
      )}
      
      {/* セレクトトリガー */}
      <button
        {...mergeProps(triggerProps, focusProps)}
        ref={ref}
        style={getTriggerStyles()}
      >
        <span {...valueProps} style={{ display: 'block' }}>
          {state.selectedItem ? state.selectedItem.rendered : 'オプションを選択'}
        </span>
        
        {/* ドロップダウンアイコン */}
        <span style={{
          position: 'absolute',
          right: '8px',
          top: '50%',
          transform: `translateY(-50%) rotate(${state.isOpen ? '180deg' : '0deg'})`,
          transition: 'transform 0.2s ease'
        }}>
          ▼
        </span>
      </button>
      
      {/* ドロップダウンメニュー */}
      {state.isOpen && (
        <ListBox
          {...menuProps}
          state={state}
          style={{
            position: 'absolute',
            top: '100%',
            left: 0,
            right: 0,
            zIndex: 1000,
            marginTop: '4px'
          }}
        />
      )}
      
      {/* エラーメッセージ */}
      {hasError && (
        <div {...errorMessageProps} style={{
          fontSize: '14px',
          color: '#dc3545',
          marginTop: '4px'
        }}>
          {errorMessage}
        </div>
      )}
    </div>
  );
};

このセレクトコンポーネントは、キーボードナビゲーションとスクリーンリーダーに完全対応しています。

次に、リストボックスとオプションの実装を見てみましょう。

// リストボックスコンポーネント
const ListBox = ({ state, ...props }) => {
  const ref = useRef();
  const { listBoxProps } = useListBox(props, state, ref);
  
  return (
    <ul
      {...listBoxProps}
      ref={ref}
      style={{
        margin: 0,
        padding: '4px 0',
        listStyle: 'none',
        backgroundColor: 'white',
        border: '1px solid #ced4da',
        borderRadius: '4px',
        boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
        maxHeight: '200px',
        overflow: 'auto'
      }}
    >
      {[...state.collection].map(item => (
        <Option key={item.key} item={item} state={state} />
      ))}
    </ul>
  );
};

// オプションコンポーネント
const Option = ({ item, state }) => {
  const ref = useRef();
  const { optionProps, isSelected, isFocused } = useOption(
    { key: item.key }, 
    state, 
    ref
  );
  
  const getOptionStyles = () => ({
    padding: '8px 12px',
    cursor: 'pointer',
    backgroundColor: isSelected ? '#007bff' : isFocused ? '#f8f9fa' : 'transparent',
    color: isSelected ? 'white' : '#333',
    outline: 'none'
  });
  
  return (
    <li
      {...optionProps}
      ref={ref}
      style={getOptionStyles()}
    >
      {item.rendered}
    </li>
  );
};

// Item コンポーネント(選択肢の定義用)
const Item = ({ children, ...props }) => {
  return <>{children}</>;
};

useListBoxuseOptionを使って、リストの各項目を適切に実装しています。

フォーカス状態と選択状態が視覚的に分かるようになっています。

実際の使用例を見てみましょう。

// 使用例
const SelectExample = () => {
  const [selectedCountry, setSelectedCountry] = useState();
  const [selectedCategory, setSelectedCategory] = useState();
  
  return (
    <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
      <h2>アクセシブルなセレクト</h2>
      
      <Select
        label="国・地域"
        isRequired
        selectedKey={selectedCountry}
        onSelectionChange={setSelectedCountry}
        description="お住まいの国または地域を選択してください"
      >
        <Item key="jp">日本</Item>
        <Item key="us">アメリカ合衆国</Item>
        <Item key="uk">イギリス</Item>
        <Item key="ca">カナダ</Item>
        <Item key="au">オーストラリア</Item>
      </Select>
      
      <Select
        label="カテゴリ"
        selectedKey={selectedCategory}
        onSelectionChange={setSelectedCategory}
        description="興味のあるカテゴリを選択してください"
      >
        <Item key="tech">テクノロジー</Item>
        <Item key="design">デザイン</Item>
        <Item key="business">ビジネス</Item>
        <Item key="education">教育</Item>
        <Item key="health">健康</Item>
      </Select>
      
      <div style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
        <h3>選択結果</h3>
        <p>国: {selectedCountry || '未選択'}</p>
        <p>カテゴリ: {selectedCategory || '未選択'}</p>
      </div>
    </div>
  );
};

キーボードの矢印キーで項目を選択でき、Enterキーで確定できます。

とても使いやすいセレクトコンポーネントが完成しました。

アクセシブルなモーダルダイアログ

モーダルダイアログも、アクセシビリティの実装が重要なコンポーネントです。

ダイアログコンポーネント

フォーカス管理とキーボードナビゲーションを備えたモーダルを実装しましょう。

import { useDialog } from '@react-aria/dialog';
import { useModal, useOverlay } from '@react-aria/overlays';
import { useFocusRing } from '@react-aria/focus';
import { mergeProps } from '@react-aria/utils';
import { useRef, useEffect } from 'react';

const Modal = ({ 
  isOpen, 
  onClose, 
  children, 
  title,
  size = 'medium',
  isDismissable = true 
}) => {
  const ref = useRef();
  const { overlayProps, underlayProps } = useOverlay(
    { isOpen, onClose, isDismissable },
    ref
  );
  
  const { modalProps } = useModal();
  const { dialogProps, titleProps } = useDialog({ title }, ref);
  
  const getSizeStyles = () => {
    const sizes = {
      small: { width: '400px', maxWidth: '90vw' },
      medium: { width: '600px', maxWidth: '90vw' },
      large: { width: '800px', maxWidth: '95vw' },
      fullscreen: { width: '100vw', height: '100vh' }
    };
    return sizes[size];
  };
  
  // ESCキーでの閉じる処理
  useEffect(() => {
    const handleEscape = (e) => {
      if (e.key === 'Escape' && isDismissable) {
        onClose();
      }
    };
    
    if (isOpen) {
      document.addEventListener('keydown', handleEscape);
      return () => document.removeEventListener('keydown', handleEscape);
    }
  }, [isOpen, onClose, isDismissable]);
  
  if (!isOpen) return null;
  
  return (
    <div style={{
      position: 'fixed',
      top: 0,
      left: 0,
      width: '100%',
      height: '100%',
      backgroundColor: 'rgba(0, 0, 0, 0.5)',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      zIndex: 1000
    }} {...underlayProps}>
      <div
        {...mergeProps(overlayProps, dialogProps, modalProps)}
        ref={ref}
        style={{
          backgroundColor: 'white',
          borderRadius: '8px',
          boxShadow: '0 4px 16px rgba(0, 0, 0, 0.15)',
          outline: 'none',
          ...getSizeStyles()
        }}
      >
        {/* ヘッダー */}
        <div style={{
          padding: '20px 24px 16px',
          borderBottom: '1px solid #e9ecef',
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center'
        }}>
          <h2 {...titleProps} style={{
            margin: 0,
            fontSize: '20px',
            fontWeight: '600'
          }}>
            {title}
          </h2>
          
          {isDismissable && (
            <CloseButton onPress={onClose} />
          )}
        </div>
        
        {/* コンテンツ */}
        <div style={{ padding: '20px 24px' }}>
          {children}
        </div>
      </div>
    </div>
  );
};

このモーダルでは、useOverlayuseModalを使って、適切なフォーカス管理を実装しています。

ESCキーでの閉じる機能も追加されています。

閉じるボタンのコンポーネントも見てみましょう。

// 閉じるボタン
const CloseButton = ({ onPress }) => {
  const ref = useRef();
  const { buttonProps } = useButton({ onPress, 'aria-label': 'ダイアログを閉じる' }, ref);
  const { focusProps, isFocusVisible } = useFocusRing();
  
  return (
    <button
      {...mergeProps(buttonProps, focusProps)}
      ref={ref}
      style={{
        width: '32px',
        height: '32px',
        border: 'none',
        borderRadius: '4px',
        backgroundColor: 'transparent',
        cursor: 'pointer',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        outline: isFocusVisible ? '2px solid #007bff' : 'none',
        outlineOffset: '2px'
      }}
    >
      ✕
    </button>
  );
};

aria-labelを使って、スクリーンリーダーユーザーにボタンの目的を伝えています。

確認ダイアログの実装も見てみましょう。

// 確認ダイアログ
const ConfirmDialog = ({ 
  isOpen, 
  onClose, 
  onConfirm, 
  title, 
  message,
  confirmText = '確認',
  cancelText = 'キャンセル',
  variant = 'default' 
}) => {
  const handleConfirm = () => {
    onConfirm();
    onClose();
  };
  
  const getVariantStyles = () => {
    const variants = {
      default: { color: '#007bff' },
      danger: { color: '#dc3545' },
      warning: { color: '#ffc107' },
      success: { color: '#28a745' }
    };
    return variants[variant];
  };
  
  return (
    <Modal isOpen={isOpen} onClose={onClose} title={title} size="small">
      <div style={{ textAlign: 'center' }}>
        <p style={{ 
          marginBottom: '24px', 
          fontSize: '16px',
          lineHeight: '1.5'
        }}>
          {message}
        </p>
        
        <div style={{ 
          display: 'flex', 
          gap: '12px', 
          justifyContent: 'center' 
        }}>
          <Button variant="outline" onPress={onClose}>
            {cancelText}
          </Button>
          
          <Button 
            onPress={handleConfirm}
            style={{
              backgroundColor: getVariantStyles().color,
              color: 'white'
            }}
          >
            {confirmText}
          </Button>
        </div>
      </div>
    </Modal>
  );
};

用途に応じて、異なるスタイルの確認ダイアログを作成できます。

実際の使用例を見てみましょう。

// 使用例
const ModalExample = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [isConfirmOpen, setIsConfirmOpen] = useState(false);
  const [formData, setFormData] = useState({ name: '', email: '' });
  
  const handleSave = () => {
    console.log('保存されました:', formData);
    setIsModalOpen(false);
  };
  
  const handleDelete = () => {
    console.log('削除されました');
  };
  
  return (
    <div style={{ padding: '20px' }}>
      <h2>モーダルダイアログ例</h2>
      
      <div style={{ display: 'flex', gap: '12px' }}>
        <Button onPress={() => setIsModalOpen(true)}>
          フォームを開く
        </Button>
        
        <Button 
          variant="outline" 
          onPress={() => setIsConfirmOpen(true)}
          style={{ color: '#dc3545', borderColor: '#dc3545' }}
        >
          削除の確認
        </Button>
      </div>
      
      {/* フォームモーダル */}
      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        title="ユーザー情報の編集"
        size="medium"
      >
        <div>
          <TextField
            label="名前"
            isRequired
            value={formData.name}
            onChange={(value) => setFormData(prev => ({ ...prev, name: value }))}
            placeholder="お名前を入力"
          />
          
          <TextField
            label="メールアドレス"
            type="email"
            isRequired
            value={formData.email}
            onChange={(value) => setFormData(prev => ({ ...prev, email: value }))}
            placeholder="email@example.com"
          />
          
          <div style={{ 
            display: 'flex', 
            gap: '12px', 
            justifyContent: 'flex-end',
            marginTop: '24px'
          }}>
            <Button variant="outline" onPress={() => setIsModalOpen(false)}>
              キャンセル
            </Button>
            <Button onPress={handleSave}>
              保存
            </Button>
          </div>
        </div>
      </Modal>
      
      {/* 確認ダイアログ */}
      <ConfirmDialog
        isOpen={isConfirmOpen}
        onClose={() => setIsConfirmOpen(false)}
        onConfirm={handleDelete}
        title="削除の確認"
        message="この操作は取り消すことができません。本当に削除しますか?"
        confirmText="削除する"
        cancelText="キャンセル"
        variant="danger"
      />
    </div>
  );
};

フォーカス管理が完全に機能し、キーボードだけでも操作できるモーダルが完成しました。

とても使いやすいUIになっています。

カスタムフックとユーティリティ

再利用可能なアクセシビリティフック

アクセシビリティ機能を再利用するための、カスタムフックを作成しましょう。

フォーカス管理フック

フォーカス管理を簡単に実装できるフックです。

import { useRef, useEffect } from 'react';
import { useFocusManager } from '@react-aria/focus';

// フォーカス管理のカスタムフック
export const useFocusManagement = (isActive = false) => {
  const focusManager = useFocusManager();
  const containerRef = useRef();
  const previousActiveElement = useRef();
  
  useEffect(() => {
    if (isActive && containerRef.current) {
      // 現在のフォーカス要素を保存
      previousActiveElement.current = document.activeElement;
      
      // コンテナ内の最初のフォーカス可能要素にフォーカス
      const firstFocusable = containerRef.current.querySelector(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      
      if (firstFocusable) {
        firstFocusable.focus();
      }
      
      return () => {
        // 元の要素にフォーカスを戻す
        if (previousActiveElement.current) {
          previousActiveElement.current.focus();
        }
      };
    }
  }, [isActive]);
  
  const trapFocus = (event) => {
    if (!containerRef.current) return;
    
    const focusableElements = containerRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    
    if (event.key === 'Tab') {
      if (event.shiftKey) {
        if (document.activeElement === firstElement) {
          event.preventDefault();
          lastElement.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          event.preventDefault();
          firstElement.focus();
        }
      }
    }
  };
  
  return {
    containerRef,
    trapFocus,
    focusFirst: () => {
      const firstFocusable = containerRef.current?.querySelector(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
    },
    focusLast: () => {
      const focusableElements = containerRef.current?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      const lastElement = focusableElements?.[focusableElements.length - 1];
      lastElement?.focus();
    }
  };
};

このフックを使えば、フォーカストラップを簡単に実装できます。

使用例を見てみましょう。

// 使用例
const FocusTrappedDialog = ({ isOpen, onClose, children }) => {
  const { containerRef, trapFocus } = useFocusManagement(isOpen);
  
  if (!isOpen) return null;
  
  return (
    <div
      ref={containerRef}
      onKeyDown={trapFocus}
      style={{
        position: 'fixed',
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%)',
        backgroundColor: 'white',
        padding: '20px',
        border: '1px solid #ccc',
        borderRadius: '8px',
        zIndex: 1000
      }}
    >
      {children}
      <Button onPress={onClose}>閉じる</Button>
    </div>
  );
};

とても簡単に、フォーカス管理機能を追加できます。

ライブリージョンフック

動的コンテンツの変更をスクリーンリーダーに通知するフックです。

import { useRef, useCallback } from 'react';

// ライブリージョン(動的コンテンツの通知)フック
export const useLiveRegion = (politeness = 'polite') => {
  const liveRegionRef = useRef();
  
  const announce = useCallback((message, priority = politeness) => {
    if (!liveRegionRef.current) {
      // ライブリージョンを動的に作成
      const liveRegion = document.createElement('div');
      liveRegion.setAttribute('aria-live', priority);
      liveRegion.setAttribute('aria-atomic', 'true');
      liveRegion.style.position = 'absolute';
      liveRegion.style.left = '-10000px';
      liveRegion.style.width = '1px';
      liveRegion.style.height = '1px';
      liveRegion.style.overflow = 'hidden';
      
      document.body.appendChild(liveRegion);
      liveRegionRef.current = liveRegion;
    }
    
    // メッセージを設定(スクリーンリーダーが読み上げる)
    liveRegionRef.current.textContent = message;
    
    // 一定時間後にクリア
    setTimeout(() => {
      if (liveRegionRef.current) {
        liveRegionRef.current.textContent = '';
      }
    }, 1000);
  }, [politeness]);
  
  const announcePolite = useCallback((message) => {
    announce(message, 'polite');
  }, [announce]);
  
  const announceAssertive = useCallback((message) => {
    announce(message, 'assertive');
  }, [announce]);
  
  return {
    announce,
    announcePolite,
    announceAssertive
  };
};

このフックを使えば、ユーザーに重要な情報を音声で通知できます。

実際の使用例を見てみましょう。

// 使用例:フォーム送信通知
const FormWithNotification = () => {
  const { announce } = useLiveRegion();
  const [formData, setFormData] = useState({ name: '', email: '' });
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    
    try {
      // フォーム送信処理
      await submitForm(formData);
      announce('フォームが正常に送信されました');
    } catch (error) {
      announce('エラーが発生しました。もう一度お試しください');
    } finally {
      setIsSubmitting(false);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <TextField
        label="名前"
        value={formData.name}
        onChange={(value) => setFormData(prev => ({ ...prev, name: value }))}
      />
      
      <TextField
        label="メールアドレス"
        type="email"
        value={formData.email}
        onChange={(value) => setFormData(prev => ({ ...prev, email: value }))}
      />
      
      <Button type="submit" isDisabled={isSubmitting}>
        {isSubmitting ? '送信中...' : '送信'}
      </Button>
    </form>
  );
};

フォーム送信の結果を、スクリーンリーダーユーザーに音声で通知できます。

キーボードショートカットフック

キーボードショートカットを簡単に実装できるフックです。

import { useEffect, useCallback } from 'react';

// キーボードショートカット管理フック
export const useKeyboardShortcuts = (shortcuts, isActive = true) => {
  const handleKeyDown = useCallback((event) => {
    if (!isActive) return;
    
    for (const shortcut of shortcuts) {
      const { 
        key, 
        ctrl = false, 
        shift = false, 
        alt = false, 
        meta = false,
        handler,
        preventDefault = true 
      } = shortcut;
      
      const matchesModifiers = 
        event.ctrlKey === ctrl &&
        event.shiftKey === shift &&
        event.altKey === alt &&
        event.metaKey === meta;
      
      const matchesKey = event.key.toLowerCase() === key.toLowerCase();
      
      if (matchesKey && matchesModifiers) {
        if (preventDefault) {
          event.preventDefault();
        }
        handler(event);
        break;
      }
    }
  }, [shortcuts, isActive]);
  
  useEffect(() => {
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [handleKeyDown]);
};

このフックを使えば、複雑なキーボードショートカットを簡単に実装できます。

使用例を見てみましょう。

// 使用例:エディターアプリケーション
const TextEditor = () => {
  const [content, setContent] = useState('');
  const [isBold, setIsBold] = useState(false);
  const [isItalic, setIsItalic] = useState(false);
  const { announce } = useLiveRegion();
  
  const shortcuts = [
    {
      key: 's',
      ctrl: true,
      handler: () => {
        saveContent(content);
        announce('ドキュメントが保存されました');
      }
    },
    {
      key: 'b',
      ctrl: true,
      handler: () => {
        setIsBold(!isBold);
        announce(isBold ? '太字を解除しました' : '太字を適用しました');
      }
    },
    {
      key: 'i',
      ctrl: true,
      handler: () => {
        setIsItalic(!isItalic);
        announce(isItalic ? '斜体を解除しました' : '斜体を適用しました');
      }
    },
    {
      key: 'z',
      ctrl: true,
      handler: () => {
        // undo処理
        announce('操作を取り消しました');
      }
    }
  ];
  
  useKeyboardShortcuts(shortcuts);
  
  return (
    <div style={{ padding: '20px' }}>
      <div style={{ marginBottom: '20px' }}>
        <h2>テキストエディター</h2>
        <div style={{ fontSize: '14px', color: '#666' }}>
          ショートカット: Ctrl+S (保存), Ctrl+B (太字), Ctrl+I (斜体), Ctrl+Z (取り消し)
        </div>
      </div>
      
      <div style={{ marginBottom: '16px' }}>
        <Button 
          onPress={() => setIsBold(!isBold)}
          variant={isBold ? 'primary' : 'outline'}
          style={{ marginRight: '8px' }}
        >
          <strong>B</strong>
        </Button>
        
        <Button 
          onPress={() => setIsItalic(!isItalic)}
          variant={isItalic ? 'primary' : 'outline'}
        >
          <em>I</em>
        </Button>
      </div>
      
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        style={{
          width: '100%',
          height: '300px',
          padding: '12px',
          border: '2px solid #ced4da',
          borderRadius: '4px',
          fontSize: '16px',
          fontWeight: isBold ? 'bold' : 'normal',
          fontStyle: isItalic ? 'italic' : 'normal',
          resize: 'vertical'
        }}
        placeholder="ここに文章を入力してください..."
        aria-label="文章入力エリア"
      />
    </div>
  );
};

キーボードショートカットの操作結果を、音声で通知することもできます。

とても使いやすいエディターが完成しました。

実践的なガイドライン

アクセシビリティテスト

作成したコンポーネントのアクセシビリティをテストする方法を見てみましょう。

自動テストツール

React Testing Libraryを使って、アクセシビリティテストを作成できます。

// React Testing Library を使用したアクセシビリティテスト
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import userEvent from '@testing-library/user-event';

expect.extend(toHaveNoViolations);

describe('Button accessibility', () => {
  test('should not have accessibility violations', async () => {
    const { container } = render(
      <Button onPress={() => {}}>テストボタン</Button>
    );
    
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
  
  test('should be keyboard accessible', async () => {
    const handlePress = jest.fn();
    render(<Button onPress={handlePress}>テストボタン</Button>);
    
    const button = screen.getByRole('button', { name: 'テストボタン' });
    
    // フォーカス可能であることを確認
    button.focus();
    expect(button).toHaveFocus();
    
    // Enterキーで実行できることを確認
    await userEvent.keyboard('{Enter}');
    expect(handlePress).toHaveBeenCalled();
    
    // Spaceキーで実行できることを確認
    await userEvent.keyboard(' ');
    expect(handlePress).toHaveBeenCalledTimes(2);
  });
  
  test('should have proper ARIA attributes', () => {
    render(
      <Button 
        onPress={() => {}} 
        isDisabled={true}
        aria-describedby="help-text"
      >
        無効なボタン
      </Button>
    );
    
    const button = screen.getByRole('button');
    expect(button).toHaveAttribute('aria-disabled', 'true');
    expect(button).toHaveAttribute('aria-describedby', 'help-text');
  });
});

これらのテストを実行することで、アクセシビリティの問題を早期に発見できます。

テストフレームワークを使って、継続的に品質を保つことができます。

手動テストのチェックリスト

自動テストだけでなく、手動でのテストも重要です。

// アクセシビリティ手動テストチェックリスト
const AccessibilityChecklist = {
  keyboard: {
    title: 'キーボードアクセシビリティ',
    checks: [
      'Tab キーですべての操作可能要素にフォーカスできる',
      'Shift+Tab で逆順にフォーカス移動できる',
      'Enter/Space キーでボタンを実行できる',
      'Arrow キーでリスト項目を移動できる',
      'Escape キーでダイアログを閉じることができる',
      'フォーカス順序が論理的である',
      'フォーカス表示が明確に見える'
    ]
  },
  
  screenReader: {
    title: 'スクリーンリーダー対応',
    checks: [
      'すべての画像に適切な alt テキストがある',
      'フォーム要素にラベルが関連付けられている',
      'ボタンの目的が明確に説明されている',
      'エラーメッセージが適切に通知される',
      'ページの構造が見出しで適切に表現されている',
      'リストが適切にマークアップされている',
      'テーブルにヘッダーが設定されている'
    ]
  },
  
  visual: {
    title: '視覚的アクセシビリティ',
    checks: [
      '色のコントラスト比がWCAG基準を満たしている',
      '色以外でも情報を伝えている',
      'テキストサイズを200%に拡大しても使用できる',
      'アニメーションを無効にできる',
      'フォーカス表示が十分に目立つ'
    ]
  },
  
  motor: {
    title: '運動機能アクセシビリティ',
    checks: [
      'クリック領域が十分に大きい(44px以上)',
      'ドラッグ操作に代替手段がある',
      'タイムアウトを無効にできる',
      '誤操作を防ぐ確認機能がある'
    ]
  }
};

これらのチェックリストを使って、体系的にテストすることが重要です。

パフォーマンス最適化

React Ariaを使ったコンポーネントのパフォーマンス最適化方法です。

メモ化とコード分割

重いコンポーネントは、メモ化とコード分割で最適化できます。

import { memo, useMemo, lazy, Suspense } from 'react';

// 重いコンポーネントのメモ化
const ExpensiveAccessibleComponent = memo(({ 
  data, 
  onItemSelect, 
  isSelected 
}) => {
  // 計算量の多い処理をメモ化
  const processedData = useMemo(() => {
    return data.map(item => ({
      ...item,
      searchableText: `${item.title} ${item.description}`.toLowerCase()
    }));
  }, [data]);
  
  const { listBoxProps } = useListBox({
    items: processedData,
    onSelectionChange: onItemSelect
  });
  
  return (
    <div {...listBoxProps}>
      {processedData.map(item => (
        <AccessibleListItem 
          key={item.id} 
          item={item}
          isSelected={isSelected(item.id)}
        />
      ))}
    </div>
  );
});

// 遅延ローディング
const LazyComplexComponent = lazy(() => import('./ComplexAccessibleComponent'));

const OptimizedApp = () => {
  return (
    <div>
      {/* 基本コンポーネントは即座に読み込み */}
      <AccessibleNavigation />
      
      {/* 複雑なコンポーネントは遅延読み込み */}
      <Suspense fallback={<AccessibleLoadingSpinner />}>
        <LazyComplexComponent />
      </Suspense>
    </div>
  );
};

適切な最適化により、パフォーマンスを維持しながらアクセシビリティを提供できます。

バンドル最適化のポイントも押さえておきましょう。

// バンドル最適化のためのインポート
const SelectiveImports = () => {
  // ❌ 全体をインポート(重い)
  // import * as ReactAria from '@react-aria/interactions';
  
  // ✅ 必要な部分のみインポート(軽い)
  import { useButton } from '@react-aria/button';
  import { useFocusRing } from '@react-aria/focus';
};

必要な部分だけをインポートすることで、バンドルサイズを小さく保てます。

まとめ

React Ariaを使って、アクセシブルなUIコンポーネントを作る方法を説明しました。

React Ariaの魅力

  • フックベース: ロジックとUIの分離で、再利用性抜群
  • WAI-ARIA準拠: 国際標準に基づく信頼性の高いアクセシビリティ
  • ヘッドレス: 完全に自由なデザインカスタマイズ
  • 包括的: 簡単なボタンから複雑なダイアログまで対応

実装のコツ

  • 段階的導入: 既存プロジェクトでも、部分的に採用できます
  • 適切なテスト: 自動テストと手動テストの両方で品質を保つ
  • パフォーマンス: メモ化とコード分割で最適化
  • チーム共有: アクセシビリティの重要性をチーム全体で理解

アクセシビリティの意味

  • 包摂性: すべてのユーザーが使えるWebサイト
  • 法的要求: WCAG、ADAなどの基準への準拠
  • SEO効果: セマンティックなマークアップによる検索性向上
  • 開発効率: 一貫性のあるコンポーネント設計

今後の取り組み

  • 早期対応: 設計段階からアクセシビリティを考慮
  • 継続的改善: 開発プロセスに組み込んだテスト
  • ユーザーの声: 実際の利用者からのフィードバック収集
  • 知識向上: チーム全体でのスキルアップ

React Ariaを活用することで、高品質でアクセシブルなWebアプリケーションを作ることができます。

すべてのユーザーにとって使いやすいインターフェースを提供し、より良いWebの実現に貢献しましょう。

アクセシビリティは、現代のWeb開発において必須の要素です。 ぜひ、実際のプロジェクトで試してみてください!

きっと、すべてのユーザーに愛されるWebアプリケーションが作れますよ。

関連記事