はじめに
ドロップダウンメニューやモーダルダイアログを自作したとき、「見た目はできたけど、キーボード操作やスクリーンリーダー対応まで手が回らない…」と感じたことはありませんか?
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以上が必要です。
Headless UIのサンプルを動かす
さっそく動かしてみましょう。以下はMenu・MenuButton・MenuItems・MenuItemを組み合わせたドロップダウンメニューです。「アカウント」ボタンをクリックするとメニューが開閉し、マウスホバーやキーボードの矢印キーで項目にフォーカスすると、その項目に自動でdata-focus属性が付与されてハイライトされます。
要点だけを抜き出すと、次のような構成になっています。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
<Menu>
<MenuButton>アカウント</MenuButton>
<MenuItems anchor="bottom start">
<MenuItem>
<div className="menu-item">プロフィール</div>
</MenuItem>
</MenuItems>
</Menu>
実際に触れるのが下のサンプルです。ボタンをクリックしてメニューを開閉し、各項目にカーソルを合わせてハイライトの変化を確認してみてください。
MenuItemsのanchor="bottom start"を"right start"に変えるとメニューが開く方向が変わり、CSSの.menu-item[data-focus]のスタイルを書き換えればホバー時の色を自由に変更できます。開閉のロジックやキーボードでのフォーカス移動を一切書いていない点が、Headless UIのMenuコンポーネントの真価です。
基本的な使い方
もっとも利用頻度の高い**ドロップダウンメニュー(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>
</>
)
}
実際に触れるのが下のサンプルです。「削除」ボタンを押すとDialogが開き、DialogPanelの外側をクリックするかonCloseが呼ばれると閉じます。キャンセルと削除、どちらを選んだかはページ上のログにその場で反映されます。
onCloseに渡した関数はEscキーを押したときやDialogPanelの外側をクリックしたときにも自動で呼ばれるため、閉じる処理を個別に書く必要がありません。DialogTitleや本文の文言を書き換えれば、そのまま別の確認モーダルに転用できます。
検索付きセレクトボックス(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対応はすべてライブラリ側が引き受けてくれます。「ロジックの自由度」と「面倒な部分の自動化」のバランスが絶妙です。
実際に入力して試せるのが下のサンプルです。テキストボックスに「た」と入力すると、ComboboxOptionsの候補がfilteredの絞り込み結果に応じてリアルタイムに変わります。項目を選ぶとComboboxのvalueが更新され、選択結果が下の表示にすぐ反映されます。
queryで絞り込んでいるpeople配列を自分のデータに差し替えれば、そのまま担当者検索や商品検索のUIに転用できます。選択結果はComboboxのvalue(この例ではselectedステート)に入るため、「選択中: 」の表示のように画面へ即座に反映できるのもポイントです。
Vueでも同じ感覚で使える(Switch)
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"やキーボード操作は自動で組み込まれます。
実際に切り替えて確かめられるのが下のサンプルです。トグルをクリックするたびにenabledの値が反転し、SwitchのON/OFF表示とノブの位置がリアルタイムに変わります。
v-modelだけで双方向バインディングが完成しており、Switchが内部でrole="switch"とaria-checkedを状態に応じて自動的に切り替えています。色やノブのアニメーションは.toggle-on・.toggle-offのCSSを書き換えるだけで自由に変更できます。
まとめ
Headless UIは、「アクセシビリティと振る舞いの実装コスト」と「デザインの自由度」を両立させたいプロジェクトにぴったりのライブラリです。
- スタイルは一切付属しないので、デザインシステムとの衝突が起きません
- キーボード操作・ARIA対応が組み込み済みで、品質の高いUIを短時間で実装できます
- Tailwind CSSのdata属性バリアントと組み合わせると、状態別スタイリングが驚くほど簡潔に書けます
- ReactとVueの両方で、同じ思想のAPIが使えます
Tailwind CSSをすでに使っているなら、導入しない理由がないほど相性が良いライブラリです。まずはMenuやDialogといった定番コンポーネントから置き換えて、その快適さを体験してみてください。
より多くのコンポーネントが必要になったら、同系統のライブラリであるRadix UIやArk UIと比較検討してみるのもおすすめです。