T-CREATOR

Headless UI とは?コンポーネントの中身だけで柔軟な UI を実現する仕組みを徹底解説

Headless UI とは?コンポーネントの中身だけで柔軟な UI を実現する仕組みを徹底解説

近年のフロントエンド開発では、見た目と振る舞いを分離する設計思想が注目を集めています。その中核を担うのが「Headless UI」という概念です。

従来の UI ライブラリは、ボタンやモーダルといったコンポーネントに対して、デザインと機能の両方をセットで提供してきました。しかし、プロジェクト固有のデザインシステムに合わせようとすると、既存のスタイルを上書きする作業が煩雑になり、結果的にメンテナンス性が低下するという課題がありました。

Headless UI は、この課題を根本から解決します。本記事では、Headless UI の基本概念から実装方法、そして実際の開発現場でどのように活用すべきかを、初心者にもわかりやすく解説していきます。

背景

従来の UI ライブラリが抱えていた制約

React、Vue、Angular などのフロントエンドフレームワークが普及する中、多くの UI ライブラリが登場してきました。Material UI、Ant Design、Chakra UI などは、すぐに使える美しいコンポーネントを提供し、開発速度を大幅に向上させました。

しかし、これらのライブラリには共通の課題がありました。それは「スタイルと機能が密結合している」ということです。

例えば、企業のブランドガイドラインに沿ったデザインを実現しようとすると、ライブラリが提供するデフォルトのスタイルを上書きする必要があります。CSS のクラス名を調べ、詳細度を上げて上書きし、さらにライブラリのバージョンアップで内部構造が変わると、また調整が必要になるという悪循環に陥ります。

デザインシステムとコンポーネントの乖離

現代の Web 開発では、Figma などのデザインツールで作成されたデザインシステムを、そのまま実装に落とし込む必要があります。しかし、既存の UI ライブラリは独自のデザイン思想を持っているため、完全に一致させることが困難でした。

以下の図は、従来の UI ライブラリとデザインシステムの関係を示しています。

mermaidflowchart TB
  design["デザインシステム<br/>(Figma・Sketch)"] -->|理想の見た目| gap["乖離"]
  uiLib["UIライブラリ<br/>(Material-UI・Chakra)"] -->|固定スタイル| gap
  gap -->|CSS上書き| override["詳細度の戦い"]
  override -->|保守困難| maintenance["メンテナンス負債"]

このように、デザインシステムと UI ライブラリの間に乖離が生じ、それを埋めるために多くのカスタマイズコードが必要となります。

アクセシビリティの実装負担

もう一つの重要な背景として、Web アクセシビリティへの意識の高まりがあります。WAI-ARIA に準拠したコンポーネントを自作するには、キーボードナビゲーション、スクリーンリーダー対応、フォーカス管理など、多くの知識と実装が必要です。

しかし、既存の UI ライブラリを使うと、前述のデザインカスタマイズの問題に直面します。アクセシビリティを保ちながら、自由なデザインを実現する方法が求められていたのです。

課題

スタイル上書きの複雑性

従来の UI ライブラリでは、スタイルをカスタマイズする際に以下のような問題が発生します。

#課題具体例
1詳細度の競合!importantの乱用によりスタイルが管理不能に
2内部構造への依存ライブラリのバージョンアップでクラス名が変更され破綻
3テーマシステムの制約提供されたテーマ API 以上のカスタマイズができない
4バンドルサイズの増加使わないスタイルも含めて読み込まれる

以下は、Material UI のボタンをカスタマイズする場合の典型的なコード例です。

typescriptimport { Button } from '@mui/material';
import { styled } from '@mui/material/styles';

上記のようにインポートした後、スタイルを上書きする必要があります。

typescript// Material UIのスタイルを上書きする
const CustomButton = styled(Button)(({ theme }) => ({
  // 既存のスタイルを打ち消す
  backgroundColor: 'transparent',
  border: 'none',
  boxShadow: 'none',

  // 新しいスタイルを適用
  borderRadius: theme.spacing(1),
  padding: `${theme.spacing(2)} ${theme.spacing(4)}`,

  // hover時のスタイルも上書き
  '&:hover': {
    backgroundColor: 'transparent',
    boxShadow: 'none',
  },
}));

このコードからわかるように、既存のスタイルを一度打ち消してから、新しいスタイルを適用する必要があります。これは非効率であり、保守性も低下させます。

アクセシビリティ実装の再発明

アクセシブルなドロップダウンメニューを自作する場合、以下のような実装が必要になります。

typescriptimport { useState, useRef, useEffect } from 'react';

interface MenuProps {
  items: string[];
}

状態管理と DOM 参照の準備をした後、キーボードイベントを処理します。

typescriptconst AccessibleMenu: React.FC<MenuProps> = ({ items }) => {
  const [isOpen, setIsOpen] = useState(false);
  const [focusedIndex, setFocusedIndex] = useState(0);
  const menuRef = useRef<HTMLUListElement>(null);

  // キーボードナビゲーションの実装
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setFocusedIndex((prev) =>
          prev < items.length - 1 ? prev + 1 : 0
        );
        break;
      case 'ArrowUp':
        e.preventDefault();
        setFocusedIndex((prev) =>
          prev > 0 ? prev - 1 : items.length - 1
        );
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  };

さらに、フォーカス管理と ARIA 属性の設定も必要です。

typescript  // フォーカス管理
  useEffect(() => {
    if (isOpen && menuRef.current) {
      const items = menuRef.current.querySelectorAll('[role="menuitem"]');
      (items[focusedIndex] as HTMLElement)?.focus();
    }
  }, [focusedIndex, isOpen]);

  return (
    <div>
      <button
        aria-haspopup="true"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
      >
        メニュー
      </button>
      {isOpen && (
        <ul
          ref={menuRef}
          role="menu"
          onKeyDown={handleKeyDown}
        >
          {items.map((item, index) => (
            <li key={item} role="menuitem" tabIndex={-1}>
              {item}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

このように、アクセシブルなコンポーネントを実装するには、多くのロジックとイベント処理が必要になります。これらを毎回実装するのは非効率であり、バグの温床にもなります。

チーム開発での一貫性の欠如

複数の開発者が異なる方法で UI コンポーネントをカスタマイズすると、コードベース全体の一貫性が失われます。

mermaidflowchart LR
  dev1["開発者A"] -->|CSS上書き| code1["直接スタイル"]
  dev2["開発者B"] -->|styled-components| code2["SC拡張"]
  dev3["開発者C"] -->|テーマAPI| code3["テーマ上書き"]
  code1 & code2 & code3 -->|統一困難| chaos["カオス"]
  chaos -->|レビュー負担| slow["開発速度低下"]

各開発者が異なるアプローチを取ることで、コードレビューの負担が増加し、新メンバーの学習コストも上がります。

解決策

Headless UI の基本概念

Headless UI は、「見た目(スタイル)」と「振る舞い(ロジック・アクセシビリティ)」を完全に分離する設計パターンです。「Headless(頭がない)」という名前が示す通り、UI の「見た目」部分を持たず、機能とアクセシビリティだけを提供します。

具体的には、以下の要素を提供します。

#提供要素内容
1状態管理開閉状態、選択状態、フォーカス状態など
2キーボードナビゲーション矢印キー、Enter、Escape などの処理
3ARIA 属性rolearia-expandedaria-selectedなど
4フォーカス管理フォーカストラップ、初期フォーカスの設定
5イベントハンドラクリック、キー入力、マウスオーバーなどの処理

スタイルは一切含まれていないため、開発者は自由に CSS や Tailwind CSS を使ってデザインできます。

アーキテクチャの全体像

Headless UI のアーキテクチャは、以下のように階層化されています。

mermaidflowchart TB
  subgraph ui["プレゼンテーション層"]
    styleLayer["スタイル<br />(CSS・Tailwind)"]
  end

  subgraph logic["ロジック層"]
    headless["Headless UI<br />(状態・ARIA・イベント)"]
  end

  subgraph framework["フレームワーク層"]
    react["React / Vue"]
  end

  styleLayer --|見た目を適用|--> headless
  headless --|フックを提供|--> react
  react --|コンポーネント化|--> app["アプリケーション"]

この構造により、各層が独立して開発・テスト・保守できるようになります。

主要な Headless UI ライブラリ

現在、いくつかの優れた Headless UI ライブラリが存在します。

#ライブラリ名対応フレームワーク特徴
1Headless UIReact、VueTailwind CSS 公式チームが開発
2Radix UIReactプリミティブなコンポーネントを提供
3React AriaReactAdobe が開発、高度なアクセシビリティ
4DownshiftReactPaypal が開発、柔軟な API
5ReakitReactコンポジション重視の設計

本記事では、最も広く使われている Headless UI ライブラリを例に説明を進めます。

実装パターンの比較

従来の UI ライブラリと Headless UI の実装を比較してみましょう。

従来の UI ライブラリ(Material UI)を使用した場合:

typescriptimport { Menu, MenuItem, Button } from '@mui/material';
import { useState } from 'react';

const TraditionalMenu = () => {
  const [anchorEl, setAnchorEl] =
    useState<null | HTMLElement>(null);

  return (
    <>
      <Button onClick={(e) => setAnchorEl(e.currentTarget)}>
        開く
      </Button>
      <Menu
        anchorEl={anchorEl}
        open={Boolean(anchorEl)}
        onClose={() => setAnchorEl(null)}
      >
        <MenuItem>項目1</MenuItem>
        <MenuItem>項目2</MenuItem>
      </Menu>
    </>
  );
};

この実装では、Material UI のスタイルがデフォルトで適用されます。

一方、Headless UI を使用した場合:

typescriptimport { Menu } from '@headlessui/react';

const HeadlessMenu = () => {
  return (
    <Menu>
      <Menu.Button className='px-4 py-2 bg-blue-500 text-white rounded'>
        開く
      </Menu.Button>
      <Menu.Items className='absolute mt-2 w-56 bg-white shadow-lg rounded-md'>
        <Menu.Item>
          {({ active }) => (
            <a
              className={`block px-4 py-2 ${
                active ? 'bg-blue-100' : ''
              }`}
              href='#'
            >
              項目1
            </a>
          )}
        </Menu.Item>
        <Menu.Item>
          {({ active }) => (
            <a
              className={`block px-4 py-2 ${
                active ? 'bg-blue-100' : ''
              }`}
              href='#'
            >
              項目2
            </a>
          )}
        </Menu.Item>
      </Menu.Items>
    </Menu>
  );
};

Headless UI では、スタイルを完全にコントロールできます。Tailwind CSS のクラスを使って、自由にデザインを適用できるのです。

具体例

プロジェクトのセットアップ

まず、Headless UI をプロジェクトに導入します。

bash# Yarnを使用してHeadless UIをインストール
yarn add @headlessui/react

Tailwind CSS と併用する場合は、以下のパッケージも追加します。

bash# Tailwind CSSの関連パッケージをインストール
yarn add -D tailwindcss postcss autoprefixer
yarn tailwindcss init -p

TypeScript プロジェクトの場合、型定義は既に含まれているため、追加のインストールは不要です。

ダイアログコンポーネントの実装

実際のプロジェクトでよく使われるダイアログ(モーダル)を実装してみましょう。

まず、必要なインポートと型定義を行います。

typescriptimport { Dialog, Transition } from '@headlessui/react';
import { Fragment, useState } from 'react';

interface ConfirmDialogProps {
  title: string;
  message: string;
  onConfirm: () => void;
  onCancel: () => void;
}

次に、コンポーネントの本体を実装します。

typescriptexport const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
  title,
  message,
  onConfirm,
  onCancel,
}) => {
  const [isOpen, setIsOpen] = useState(false);

  const handleConfirm = () => {
    onConfirm();
    setIsOpen(false);
  };

  const handleCancel = () => {
    onCancel();
    setIsOpen(false);
  };

ダイアログのトリガーボタンとダイアログ本体を配置します。

typescript  return (
    <>
      <button
        onClick={() => setIsOpen(true)}
        className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
      >
        削除
      </button>

      <Transition appear show={isOpen} as={Fragment}>
        <Dialog
          as="div"
          className="relative z-50"
          onClose={handleCancel}
        >
          {/* 背景オーバーレイ */}
          <Transition.Child
            as={Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0"
            enterTo="opacity-100"
            leave="ease-in duration-200"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
          >
            <div className="fixed inset-0 bg-black bg-opacity-25" />
          </Transition.Child>

最後に、ダイアログのコンテンツ部分を実装します。

typescript          {/* ダイアログコンテンツ */}
          <div className="fixed inset-0 overflow-y-auto">
            <div className="flex min-h-full items-center justify-center p-4">
              <Transition.Child
                as={Fragment}
                enter="ease-out duration-300"
                enterFrom="opacity-0 scale-95"
                enterTo="opacity-100 scale-100"
                leave="ease-in duration-200"
                leaveFrom="opacity-100 scale-100"
                leaveTo="opacity-0 scale-95"
              >
                <Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 shadow-xl transition-all">
                  <Dialog.Title className="text-lg font-medium text-gray-900">
                    {title}
                  </Dialog.Title>
                  <Dialog.Description className="mt-2 text-sm text-gray-500">
                    {message}
                  </Dialog.Description>

                  <div className="mt-4 flex gap-2 justify-end">
                    <button
                      onClick={handleCancel}
                      className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
                    >
                      キャンセル
                    </button>
                    <button
                      onClick={handleConfirm}
                      className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
                    >
                      削除
                    </button>
                  </div>
                </Dialog.Panel>
              </Transition.Child>
            </div>
          </div>
        </Dialog>
      </Transition>
    </>
  );
};

このコンポーネントでは、以下の機能が自動的に提供されます。

  • フォーカストラップ: ダイアログが開いている間、フォーカスがダイアログ内に閉じ込められます
  • Escape キー: Esc キーでダイアログを閉じられます
  • 背景クリック: オーバーレイをクリックするとダイアログが閉じます
  • ARIA 属性: role="dialog"aria-modal="true"などが自動的に設定されます

セレクトボックス(Listbox)の実装

次に、カスタムデザインのセレクトボックスを実装してみましょう。

必要な型定義とインポートを行います。

typescriptimport { Listbox } from '@headlessui/react';
import { useState } from 'react';

interface Option {
  id: number;
  name: string;
}

const options: Option[] = [
  { id: 1, name: 'オプション1' },
  { id: 2, name: 'オプション2' },
  { id: 3, name: 'オプション3' },
];

セレクトボックスのコンポーネントを実装します。

typescriptexport const CustomSelect = () => {
  const [selected, setSelected] = useState(options[0]);

  return (
    <div className="w-72">
      <Listbox value={selected} onChange={setSelected}>
        <div className="relative">
          <Listbox.Button className="relative w-full cursor-pointer rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500">
            <span className="block truncate">{selected.name}</span>
            <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
              <svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
                <path fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
              </svg>
            </span>
          </Listbox.Button>

ドロップダウンのオプションリストを実装します。

typescript          <Listbox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
            {options.map((option) => (
              <Listbox.Option
                key={option.id}
                value={option}
                className={({ active }) =>
                  `relative cursor-pointer select-none py-2 pl-10 pr-4 ${
                    active ? 'bg-blue-100 text-blue-900' : 'text-gray-900'
                  }`
                }
              >
                {({ selected }) => (
                  <>
                    <span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
                      {option.name}
                    </span>
                    {selected && (
                      <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
                        <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
                          <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
                        </svg>
                      </span>
                    )}
                  </>
                )}
              </Listbox.Option>
            ))}
          </Listbox.Options>
        </div>
      </Listbox>
    </div>
  );
};

このセレクトボックスには、以下の機能が自動的に組み込まれています。

  • キーボード操作: 矢印キーで選択、Enter で決定、Escape で閉じる
  • 検索機能: 文字を入力すると該当するオプションにジャンプ
  • ARIA 属性: role="listbox"aria-selectedaria-activedescendantなど
  • スクリーンリーダー対応: 選択状態が音声で読み上げられます

アクセシビリティの自動対応

Headless UI が自動的に適用する ARIA 属性を確認してみましょう。

typescript// 開発者が書くコード
<Menu>
  <Menu.Button>メニュー</Menu.Button>
  <Menu.Items>
    <Menu.Item>項目1</Menu.Item>
  </Menu.Items>
</Menu>

実際にレンダリングされる HTML は以下のようになります。

html<!-- Headless UIが自動生成するHTML -->
<div>
  <button
    id="headlessui-menu-button-1"
    type="button"
    aria-haspopup="menu"
    aria-expanded="true"
    aria-controls="headlessui-menu-items-2"
  >
    メニュー
  </button>
  <div
    id="headlessui-menu-items-2"
    role="menu"
    aria-labelledby="headlessui-menu-button-1"
    tabindex="-1"
  >
    <a role="menuitem" tabindex="-1">項目1</a>
  </div>
</div>

このように、WAI-ARIA に準拠した属性が自動的に付与されるため、開発者はアクセシビリティの詳細を意識する必要がありません。

デザインシステムとの統合

Headless UI は、企業のデザインシステムとの統合が容易です。以下の図は、デザイントークンを使った実装パターンを示しています。

mermaidflowchart LR
  tokens["デザイントークン<br/>(色・間隔・影)"] -->|適用| headless["Headless UI<br/>コンポーネント"]
  headless -->|ラップ| custom["カスタム<br/>コンポーネント"]
  custom -->|提供| app["アプリケーション"]

  style tokens fill:#e1f5ff
  style headless fill:#fff4e1
  style custom fill:#f0ffe1
  style app fill:#ffe1e1

実装例を見てみましょう。

typescript// デザイントークンの定義
const tokens = {
  colors: {
    primary: '#3B82F6',
    secondary: '#10B981',
    danger: '#EF4444',
  },
  spacing: {
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem',
  },
  radius: {
    sm: '0.25rem',
    md: '0.5rem',
    lg: '1rem',
  },
};

トークンを使ってカスタムボタンコンポーネントを作成します。

typescriptimport { Menu } from '@headlessui/react';

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  children: React.ReactNode;
}

export const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  children,
}) => {
  const styles = {
    backgroundColor: tokens.colors[variant],
    padding: `${tokens.spacing.sm} ${tokens.spacing.md}`,
    borderRadius: tokens.radius.md,
    color: 'white',
    border: 'none',
    cursor: 'pointer',
  };

  return (
    <Menu.Button style={styles}>{children}</Menu.Button>
  );
};

このように、Headless UI をベースにしながら、企業独自のデザインシステムを適用できます。

よくあるトラブルシューティング

Headless UI を使用する際、以下のようなエラーに遭遇することがあります。

エラーケース 1: コンポーネントが正しくネストされていない

typescript// ❌ 誤った実装
<Menu.Items>
  <div>
    <Menu.Item>項目1</Menu.Item>
  </div>
</Menu.Items>

エラーコード: Error: <Menu.Item> should be a direct child of <Menu.Items>

発生条件: Menu.ItemMenu.Itemsの直接の子要素でない場合

解決方法:

typescript// ✅ 正しい実装
<Menu.Items>
  <Menu.Item>
    <div>項目1</div>
  </Menu.Item>
</Menu.Items>

Menu.ItemMenu.Itemsの直接の子要素として配置し、カスタムスタイルはMenu.Itemの内部に記述します。

エラーケース 2: 複数のダイアログで z-index が競合

typescript// ❌ z-indexが競合する可能性
<Dialog className='z-50'>{/* 内容 */}</Dialog>

エラーコード: なし(視覚的な問題)

発生条件: 複数のダイアログやポップオーバーが同時に開かれた場合

解決方法:

typescript// ✅ CSS変数を使った階層管理
// グローバルスタイル
:root {
  --z-modal: 1000;
  --z-popover: 1100;
  --z-tooltip: 1200;
}

// コンポーネント
<Dialog style={{ zIndex: 'var(--z-modal)' }}>
  {/* 内容 */}
</Dialog>

CSS 変数を使って、z-index の値を一元管理することで、競合を防ぎます。

まとめ

Headless UI は、「見た目」と「振る舞い」を分離することで、現代のフロントエンド開発における多くの課題を解決します。

本記事で解説した主要なポイントは以下の通りです。

まず、従来の UI ライブラリが抱えていた「スタイルと機能の密結合」という問題に対して、Headless UI は完全な分離を実現しました。これにより、企業のデザインシステムをそのまま適用でき、CSS 上書きの煩雑さから解放されます。

次に、アクセシビリティの実装負担が大幅に軽減されます。WAI-ARIA に準拠した ARIA 属性、キーボードナビゲーション、フォーカス管理などが自動的に提供されるため、開発者はビジネスロジックとデザインに集中できます。

さらに、チーム開発における一貫性も向上します。Headless UI をベースにカスタムコンポーネントライブラリを構築することで、プロジェクト全体で統一されたアプローチを維持できるでしょう。

一方で、Headless UI にも注意点があります。スタイルを全て自分で書く必要があるため、初期のセットアップには時間がかかります。また、Tailwind CSS などのユーティリティファーストな CSS フレームワークとの組み合わせが推奨されるため、学習コストも考慮する必要があります。

しかし、長期的なメンテナンス性、デザインの柔軟性、アクセシビリティの担保という観点から見れば、Headless UI は非常に強力な選択肢と言えます。

これからフロントエンド開発を始める方も、既存のプロジェクトをリファクタリングしたい方も、Headless UI の概念を理解し、活用していくことで、より保守性の高い、アクセシブルな Web アプリケーションを構築できるはずです。

ぜひ、実際のプロジェクトで Headless UI を試してみてください。最初は戸惑うかもしれませんが、その柔軟性と拡張性の高さに驚かされることでしょう。

関連リンク