はじめに
ドロップダウンメニューやモーダルダイアログを自作したとき、「見た目はできたけど、キーボード操作やスクリーンリーダー対応まで手が回らない…」と感じたことはありませんか?
Escキーで閉じる、フォーカスをトラップする、適切なARIA属性を付与する——こうしたアクセシビリティ対応を自前で実装するのは、想像以上に骨の折れる作業です。かといって、Material UIのようなスタイル込みのライブラリを使うと、今度はデザインのカスタマイズに苦労することになります。
そんなジレンマを解決してくれるのが、Tailwind CSSの開発元であるTailwind Labsが手がけるHeadless UIです。「振る舞いとアクセシビリティはライブラリに任せて、見た目は自分で自由に作る」という、いいとこ取りのアプローチを実現できます。
Headless UIとは
Headless UIは、完全に非スタイル(unstyled)で、完全にアクセシブルなUIコンポーネントライブラリです。Tailwind CSSとの美しい統合を前提に設計されていますが、Tailwind以外のスタイリング手法とも問題なく組み合わせられます。
GitHubスターは28,000を超えており、ReactとVueの両方に対応。2026年4月にはReact版のv2.2.10がリリースされるなど、現在も活発にメンテナンスされています。
主な特徴
- 完全非スタイル - CSSが一切付属しないため、デザインの自由度が100%。既存のデザインシステムにそのまま組み込めます
- アクセシビリティ完備 - WAI-ARIA準拠のマークアップ、キーボード操作、フォーカス管理がすべて組み込み済みです
- Tailwind CSSとの親和性 -
data-*属性による状態スタイリングで、開いた状態やアクティブな状態をTailwindのクラスだけで表現できます - React / Vue対応 -
@headlessui/reactと@headlessui/vueの2つのパッケージが公式提供されています - TypeScript製 - コードベースの95%以上がTypeScriptで書かれており、型定義も完璧です
提供されているコンポーネント
Menu(ドロップダウン)、Dialog(モーダル)、Listbox(セレクトボックス)、Combobox(オートコンプリート)、Tabs、Disclosure(アコーディオン)、Popover、Switch、Radio Group、Checkbox、Transitionなど、「自作すると大変だけど、どのプロジェクトでも必要になる」コンポーネントが厳選されています。
インストール
npmやyarn、pnpmでインストールできます。React版とVue版でパッケージが分かれている点に注意してください。
# React の場合
npm install @headlessui/react
# Vue の場合
npm install @headlessui/vue
React 18以上(v2系の場合)、Vue 3以上が必要です。
基本的な使い方
もっとも利用頻度の高い**ドロップダウンメニュー(Menu)**を例に見てみましょう。以下はReact + Tailwind CSSでの実装例です。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
export default function UserMenu() {
return (
<Menu>
<MenuButton className="rounded bg-indigo-600 px-4 py-2 text-white">
アカウント
</MenuButton>
<MenuItems
anchor="bottom start"
className="mt-1 w-48 rounded border bg-white shadow-lg"
>
<MenuItem>
<a
href="/profile"
className="block px-4 py-2 data-[focus]:bg-indigo-100"
>
プロフィール
</a>
</MenuItem>
<MenuItem>
<a
href="/settings"
className="block px-4 py-2 data-[focus]:bg-indigo-100"
>
設定
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
このわずかなコードで、次の機能がすべて自動的に手に入ります。
- クリックまたはEnter/Spaceキーでメニューを開閉
- 矢印キーで項目間を移動、Escキーで閉じる
- メニュー外クリックで自動的に閉じる
aria-haspopupやaria-expandedなどのARIA属性の自動付与
注目してほしいのがdata-[focus]:bg-indigo-100という書き方です。Headless UI v2では、コンポーネントの状態(フォーカス中、選択中、開いている等)がdata-focusやdata-selectedといったdata属性としてDOMに反映されます。Tailwindのdata-*バリアントと組み合わせれば、JavaScriptで状態を意識することなく、クラス名だけで状態別のスタイルを書けるのです。
実践的なユースケース
確認モーダル(Dialog)
削除操作の確認など、実務で必ず登場するモーダルダイアログの例です。フォーカストラップ(モーダル外にフォーカスが逃げない仕組み)やスクロールロックも自動で処理されます。
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'
import { useState } from 'react'
export default function DeleteConfirm({ onDelete }: { onDelete: () => void }) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button
onClick={() => setIsOpen(true)}
className="rounded bg-red-600 px-4 py-2 text-white"
>
削除
</button>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="max-w-sm rounded-lg bg-white p-6 shadow-xl">
<DialogTitle className="text-lg font-bold">
本当に削除しますか?
</DialogTitle>
<p className="mt-2 text-sm text-gray-600">
この操作は取り消せません。
</p>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setIsOpen(false)}
className="rounded border px-4 py-2"
>
キャンセル
</button>
<button
onClick={() => {
onDelete()
setIsOpen(false)
}}
className="rounded bg-red-600 px-4 py-2 text-white"
>
削除する
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</>
)
}
検索付きセレクトボックス(Combobox)
ユーザー数が多いシステムでの担当者選択など、「選択肢が多くて探すのが大変」な場面で活躍するのがComboboxです。
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from '@headlessui/react'
import { useState } from 'react'
const people = ['佐藤', '鈴木', '高橋', '田中', '渡辺']
export default function PersonPicker() {
const [selected, setSelected] = useState<string | null>(null)
const [query, setQuery] = useState('')
const filtered =
query === ''
? people
: people.filter((person) => person.includes(query))
return (
<Combobox value={selected} onChange={setSelected}>
<ComboboxInput
aria-label="担当者"
className="w-64 rounded border px-3 py-2"
placeholder="名前で検索..."
onChange={(event) => setQuery(event.target.value)}
/>
<ComboboxOptions
anchor="bottom"
className="w-64 rounded border bg-white shadow-lg"
>
{filtered.map((person) => (
<ComboboxOption
key={person}
value={person}
className="px-3 py-2 data-[focus]:bg-indigo-100"
>
{person}
</ComboboxOption>
))}
</ComboboxOptions>
</Combobox>
)
}
入力に応じた絞り込みロジックは自分で書きますが、キーボードナビゲーションや選択状態の管理、ARIA対応はすべてライブラリ側が引き受けてくれます。「ロジックの自由度」と「面倒な部分の自動化」のバランスが絶妙です。
Vueでも同じ感覚で使える
Vue版もAPIの設計思想は共通です。Switchコンポーネントの例を見てみましょう。
<script setup>
import { ref } from 'vue'
import { Switch } from '@headlessui/vue'
const enabled = ref(false)
</script>
<template>
<Switch
v-model="enabled"
:class="enabled ? 'bg-indigo-600' : 'bg-gray-300'"
class="relative inline-flex h-6 w-11 items-center rounded-full"
>
<span
:class="enabled ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 rounded-full bg-white transition"
/>
</Switch>
</template>
トグルスイッチの見た目は完全に自前ですが、role="switch"やキーボード操作は自動で組み込まれます。
まとめ
Headless UIは、「アクセシビリティと振る舞いの実装コスト」と「デザインの自由度」を両立させたいプロジェクトにぴったりのライブラリです。
- スタイルは一切付属しないので、デザインシステムとの衝突が起きません
- キーボード操作・ARIA対応が組み込み済みで、品質の高いUIを短時間で実装できます
- Tailwind CSSのdata属性バリアントと組み合わせると、状態別スタイリングが驚くほど簡潔に書けます
- ReactとVueの両方で、同じ思想のAPIが使えます
Tailwind CSSをすでに使っているなら、導入しない理由がないほど相性が良いライブラリです。まずはMenuやDialogといった定番コンポーネントから置き換えて、その快適さを体験してみてください。
より多くのコンポーネントが必要になったら、同系統のライブラリであるRadix UIやArk UIと比較検討してみるのもおすすめです。