mcpField
H
headlessui

headlessui

github.com/bobmatnycデザイン・UI78349 回インストール15 週間前に更新
リポジトリ

headlessui について

Headless UIは、独自のデザインを適用したいReactとVueの開発者向けの、スタイルを持たないアクセシブルなUIコンポーネントです。キーボードやスクリーンリーダー、フォーカス管理に対応し、TypeScriptとSSRも利用できます。

主な機能

  1. 01スタイルなしのコンポーネント基盤
  2. 02キーボードとスクリーンリーダー対応
  3. 03ARIA属性とフォーカスの自動管理
  4. 04ReactとVueに対応
  5. 05TypeScriptとSSRに対応

活用シーン

  1. 01デザインシステムの構築
  2. 02アクセシブルなメニューやダイアログの追加
  3. 03検索可能な選択コントロールの作成

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill headlessui

インストール後、Claude Code で/headlessuiを入力して読み込まれたか確認します。

同梱ファイルmetadata.json

詳細

Headless UI - アクセシブルなコンポーネントプリミティブ

概要

Headless UI は、Tailwind CSS と美しく統合できるよう設計された、完全にスタイル未設定で、十分なアクセシビリティを備えた UI コンポーネントを提供します。Tailwind Labs チームが開発したこのライブラリは、デザイン上の決定を押し付けることなく、本番環境で使えるアクセシビリティを提供します。

主な機能:

  • 完全にスタイル未設定 — スタイルは自由に設定できます
  • 完全なキーボードナビゲーション
  • スクリーンリーダーでテスト済み
  • フォーカス管理
  • ARIA 属性を自動処理
  • TypeScript 対応
  • React 18 および Vue 3 に対応
  • SSR 対応
  • 最大限の柔軟性を実現するレンダープロパティ

インストール:

# React
npm install @headlessui/react

# Vue
npm install @headlessui/vue

コンポーネント一覧

Menu(ドロップダウン)

キーボードナビゲーションと ARIA に対応した、アクセシブルなドロップダウンメニューです。

import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'

function DropdownMenu() {
  return (
    <Menu>
      <MenuButton className="inline-flex items-center gap-2 rounded-md bg-gray-800 py-1.5 px-3 text-sm/6 font-semibold text-white shadow-inner shadow-white/10 focus:outline-none data-[hover]:bg-gray-700 data-[open]:bg-gray-700 data-[focus]:outline-1 data-[focus]:outline-white">
        Options
        <ChevronDownIcon className="size-4 fill-white/60" />
      </MenuButton>

      <MenuItems
        transition
        anchor="bottom end"
        className="w-52 origin-top-right rounded-xl border border-white/5 bg-white/5 p-1 text-sm/6 text-white transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
      >
        <MenuItem>
          <button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 data-[focus]:bg-white/10">
            Edit
          </button>
        </MenuItem>
        <MenuItem>
          <button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 data-[focus]:bg-white/10">
            Duplicate
          </button>
        </MenuItem>
        <div className="my-1 h-px bg-white/5" />
        <MenuItem>
          <button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 data-[focus]:bg-white/10">
            Delete
          </button>
        </MenuItem>
      </MenuItems>
    </Menu>
  )
}

Menu の機能:

  • 矢印キーでのナビゲーション
  • 入力文字による検索
  • フォーカスを自動管理
  • Escape キーで閉じる
  • 外側をクリックして閉じる
  • 位置調整のためのポータルレンダリング
  • アンカー位置指定 API
Listbox(セレクト)

キーボード操作に完全対応した、カスタムのセレクト/ドロップダウンコンポーネントです。

import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
import { useState } from 'react'

const people = [
  { id: 1, name: 'Wade Cooper' },
  { id: 2, name: 'Arlene Mccoy' },
  { id: 3, name: 'Devon Webb' },
]

function SelectExample() {
  const [selected, setSelected] = useState(people[0])

  return (
    <Listbox value={selected} onChange={setSelected}>
      <ListboxButton className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
        <span className="block truncate">{selected.name}</span>
        <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
          <ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
        </span>
      </ListboxButton>

      <ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm">
        {people.map((person) => (
          <ListboxOption
            key={person.id}
            value={person}
            className="relative cursor-default select-none py-2 pl-10 pr-4 data-[focus]:bg-amber-100 data-[focus]:text-amber-900"
          >
            {({ selected }) => (
              <>
                <span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
                  {person.name}
                </span>
                {selected && (
                  <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-amber-600">
                    <CheckIcon className="h-5 w-5" aria-hidden="true" />
                  </span>
                )}
              </>
            )}
          </ListboxOption>
        ))}
      </ListboxOptions>
    </Listbox>
  )
}

Listbox の機能:

  • 単一選択モードと複数選択モード
  • 入力文字による検索
  • 矢印キーでのナビゲーション
  • 制御モードと非制御モード
  • 無効なオプションに対応
  • カスタムの値比較
Combobox(オートコンプリート)

絞り込み機能を備えた、検索可能なセレクトコンポーネントです。

import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from '@headlessui/react'
import { useState } from 'react'

const people = [
  { id: 1, name: 'Wade Cooper' },
  { id: 2, name: 'Arlene Mccoy' },
  { id: 3, name: 'Devon Webb' },
  { id: 4, name: 'Tom Cook' },
]

function AutocompleteExample() {
  const [selected, setSelected] = useState(people[0])
  const [query, setQuery] = useState('')

  const filtered =
    query === ''
      ? people
      : people.filter((person) =>
          person.name.toLowerCase().includes(query.toLowerCase())
        )

  return (
    <Combobox value={selected} onChange={setSelected}>
      <ComboboxInput
        className="w-full rounded-lg border-none bg-white/5 py-1.5 pr-8 pl-3 text-sm/6 text-white focus:outline-none data-[focus]:outline-2 data-[focus]:-outline-offset-2 data-[focus]:outline-white/25"
        displayValue={(person) => person?.name}
        onChange={(event) => setQuery(event.target.value)}
      />

      <ComboboxOptions className="w-[var(--input-width)] rounded-xl border border-white/5 bg-white/5 p-1 [--anchor-gap:var(--spacing-1)] empty:invisible">
        {filtered.map((person) => (
          <ComboboxOption
            key={person.id}
            value={person}
            className="group flex cursor-default items-center gap-2 rounded-lg py-1.5 px-3 select-none data-[focus]:bg-white/10"
          >
            <CheckIcon className="invisible size-4 fill-white group-data-[selected]:visible" />
            <div className="text-sm/6 text-white">{person.name}</div>
          </ComboboxOption>
        ))}
      </ComboboxOptions>
    </Combobox>
  )
}

Combobox の機能:

  • 絞り込み可能なテキスト入力
  • キーボードナビゲーション
  • null/任意の選択
  • カスタムの表示値
  • 非同期データ読み込みに対応
  • 複数選択モード
Dialog(モーダル)

フォーカストラップ機能を備えた、アクセシブルなモーダルダイアログです。

import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { Fragment, useState } from 'react'

function ModalExample() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Open dialog</button>

      <Transition appear show={isOpen} as={Fragment}>
        <Dialog as="div" className="relative z-10" onClose={() => setIsOpen(false)}>
          <TransitionChild
            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/25" />
          </TransitionChild>

          <div className="fixed inset-0 overflow-y-auto">
            <div className="flex min-h-full items-center justify-center p-4 text-center">
              <TransitionChild
                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"
              >
                <DialogPanel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
                  <DialogTitle className="text-lg font-medium leading-6 text-gray-900">
                    Payment successful
                  </DialogTitle>
                  <div className="mt-2">
                    <p className="text-sm text-gray-500">
                      Your payment has been successfully submitted.
                    </p>
                  </div>

                  <div className="mt-4">
                    <button
                      type="button"
                      className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
                      onClick={() => setIsOpen(false)}
                    >
                      Got it, thanks!
                    </button>
                  </div>
                </DialogPanel>
              </TransitionChild>
            </div>
          </div>
        </Dialog>
      </Transition>
    </>
  )
}

Dialog の機能:

  • フォーカストラップ
  • Escape キーで閉じる
  • スクロールをロック
  • 閉じたときにフォーカスを元に戻す
  • ポータルレンダリング
  • ネストされたダイアログに対応
  • 初期フォーカスの制御
Popover

ツールチップやドロップダウンなどに使えるフローティングパネルです。

import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'

function PopoverExample() {
  return (
    <Popover className="relative">
      <PopoverButton className="inline-flex items-center gap-2 rounded-md bg-gray-800 py-1.5 px-3 text-sm/6 font-semibold text-white shadow-inner shadow-white/10 focus:outline-none data-[hover]:bg-gray-700 data-[focus]:outline-1 data-[focus]:outline-white">
        Solutions
      </PopoverButton>

      <PopoverPanel
        transition
        anchor="bottom"
        className="divide-y divide-white/5 rounded-xl bg-white/5 text-sm/6 transition duration-200 ease-in-out [--anchor-gap:var(--spacing-5)] data-[closed]:-translate-y-1 data-[closed]:opacity-0"
      >
        <div className="p-3">
          <a className="block rounded-lg py-2 px-3 transition hover:bg-white/5" href="#">
            <p className="font-semibold text-white">Insights</p>
            <p className="text-white/50">Measure actions your users take</p>
          </a>
          <a className="block rounded-lg py-2 px-3 transition hover:bg-white/5" href="#">
            <p className="font-semibold text-white">Automations</p>
            <p className="text-white/50">Create your own targeted content</p>
          </a>
        </div>
      </PopoverPanel>
    </Popover>
  )
}

Popover の機能:

  • アンカー位置指定
  • クリックまたはホバーによるトリガー
  • 外側のクリックで閉じる
  • ネストされたポップオーバー
  • フォーカス管理
  • ポータルレンダリング
RadioGroup

アクセシブルなラジオボタングループです。

import { RadioGroup, RadioGroupOption, RadioGroupLabel } from '@headlessui/react'
import { useState } from 'react'

const plans = [
  { name: 'Startup', ram: '12GB', cpus: '6 CPUs', disk: '160 GB SSD disk' },
  { name: 'Business', ram: '16GB', cpus: '8 CPUs', disk: '512 GB SSD disk' },
  { name: 'Enterprise', ram: '32GB', cpus: '12 CPUs', disk: '1024 GB SSD disk' },
]

function RadioExample() {
  const [selected, setSelected] = useState(plans[0])

  return (
    <RadioGroup value={selected} onChange={setSelected}>
      <RadioGroupLabel className="sr-only">Server size</RadioGroupLabel>
      <div className="space-y-2">
        {plans.map((plan) => (
          <RadioGroupOption
            key={plan.name}
            value={plan}
            className="relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-md focus:outline-none data-[focus]:outline-2 data-[focus]:outline-white/75 data-[checked]:bg-sky-900/75"
          >
            <div className="flex items-center justify-between">
              <div className="flex items-center">
                <div className="text-sm">
                  <RadioGroupLabel as="p" className="font-medium text-white">
                    {plan.name}
                  </RadioGroupLabel>
                  <div className="flex gap-2 text-white/50">
                    <div>{plan.ram}</div>
                    <div aria-hidden="true">&middot;</div>
                    <div>{plan.cpus}</div>
                    <div aria-hidden="true">&middot;</div>
                    <div>{plan.disk}</div>
                  </div>
                </div>
              </div>
            </div>
          </RadioGroupOption>
        ))}
      </div>
    </RadioGroup>
  )
}

RadioGroup の機能:

  • 矢印キーでのナビゲーション
  • 無効なオプション
  • 状態に応じたカスタムスタイル
  • 制御モード
  • 説明に対応
Switch(トグル)

アクセシブルなトグルスイッチです。

import { Switch } from '@headlessui/react'
import { useState } from 'react'

function SwitchExample() {
  const [enabled, setEnabled] = useState(false)

  return (
    <Switch
      checked={enabled}
      onChange={setEnabled}
      className="group inline-flex h-6 w-11 items-center rounded-full bg-gray-200 transition data-[checked]:bg-blue-600"
    >
      <span className="size-4 translate-x-1 rounded-full bg-white transition group-data-[checked]:translate-x-6" />
    </Switch>
  )
}

Switch の機能:

  • 制御モードと非制御モード
  • ラベルに対応
  • 説明に対応
  • 無効状態
  • キーボードで操作可能(Space キーで切り替え)
Tab(タブ)

アクセシブルなタブナビゲーションです。

import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'

function TabExample() {
  const categories = [
    {
      name: 'Recent',
      posts: [
        { id: 1, title: 'Does drinking coffee make you smarter?' },
        { id: 2, title: "So you've bought coffee... now what?" },
      ],
    },
    {
      name: 'Popular',
      posts: [
        { id: 1, title: 'Is tech making coffee better or worse?' },
        { id: 2, title: 'The most innovative things happening in coffee' },
      ],
    },
  ]

  return (
    <TabGroup>
      <TabList className="flex space-x-1 rounded-xl bg-blue-900/20 p-1">
        {categories.map((category) => (
          <Tab
            key={category.name}
            className="w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-blue-700 ring-white/60 ring-offset-2 ring-offset-blue-400 focus:outline-none focus:ring-2 data-[selected]:bg-white data-[selected]:shadow data-[hover]:bg-white/[0.12] data-[focus]:outline-1"
          >
            {category.name}
          </Tab>
        ))}
      </TabList>
      <TabPanels className="mt-2">
        {categories.map((category, idx) => (
          <TabPanel
            key={idx}
            className="rounded-xl bg-white p-3 ring-white/60 ring-offset-2 ring-offset-blue-400 focus:outline-none focus:ring-2"
          >
            <ul>
              {category.posts.map((post) => (
                <li key={post.id} className="relative rounded-md p-3 hover:bg-gray-100">
                  <h3 className="text-sm font-medium leading-5">{post.title}</h3>
                </li>
              ))}
            </ul>
          </TabPanel>
        ))}
      </TabPanels>
    </TabGroup>
  )
}

Tab の機能:

  • 矢印キーでのナビゲーション
  • 初期選択タブ
  • 手動アクティベーション
  • 垂直/水平の向き
  • 制御モード
Disclosure(アコーディオン)

展開可能なコンテンツセクションです。

import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'
import { ChevronUpIcon } from '@heroicons/react/20/solid'

function DisclosureExample() {
  return (
    <Disclosure>
      {({ open }) => (
        <>
          <DisclosureButton className="flex w-full justify-between rounded-lg bg-purple-100 px-4 py-2 text-left text-sm font-medium text-purple-900 hover:bg-purple-200 focus:outline-none focus-visible:ring focus-visible:ring-purple-500/75">
            <span>What is your refund policy?</span>
            <ChevronUpIcon
              className={`${open ? 'rotate-180 transform' : ''} h-5 w-5 text-purple-500`}
            />
          </DisclosureButton>
          <DisclosurePanel className="px-4 pb-2 pt-4 text-sm text-gray-500">
            If you're unhappy with your purchase for any reason, email us within 90 days and we'll refund you in full, no questions asked.
          </DisclosurePanel>
        </>
      )}
    </Disclosure>
  )
}

Disclosure の機能:

  • 制御モードと非制御モード
  • 初期表示状態
  • 状態にアクセスするレンダープロパティ
  • 複数の Disclosure(アコーディオンパターン)
  • Transition による滑らかなアニメーション
Transition

表示/非表示の遷移を行うアニメーションコンポーネントです。

import { Transition } from '@headlessui/react'
import { useState } from 'react'

function TransitionExample() {
  const [isShowing, setIsShowing] = useState(false)

  return (
    <>
      <button onClick={() => setIsShowing(!isShowing)}>Toggle</button>
      <Transition
        show={isShowing}
        enter="transition-opacity duration-300"
        enterFrom="opacity-0"
        enterTo="opacity-100"
        leave="transition-opacity duration-200"
        leaveFrom="opacity-100"
        leaveTo="opacity-0"
      >
        <div className="rounded-md bg-blue-500 p-4 text-white">
          I will fade in and out
        </div>
      </Transition>
    </>
  )
}

Transition の機能:

  • CSS クラスベースの遷移
  • 表示/非表示のライフサイクル
  • ネストされた遷移(子要素との連携)
  • 初期マウント時のアニメーションに対応
  • React 18 の並行モードで動作

高度なパターン

レンダープロパティパターン

コンポーネントの状態にアクセスして、独自のレンダリングを行えます。

import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'

function RenderPropsExample() {
  return (
    <Listbox value={selected} onChange={setSelected}>
      {({ open }) => (
        <>
          <ListboxButton>
            Options {open ? '▲' : '▼'}
          </ListboxButton>
          <ListboxOptions>
            <ListboxOption value="a">
              {({ selected, focus }) => (
                <div className={focus ? 'bg-blue-500' : ''}>
                  {selected && '✓'} Option A
                </div>
              )}
            </ListboxOption>
          </ListboxOptions>
        </>
      )}
    </Listbox>
  )
}
制御コンポーネント

コンポーネントの状態を完全に制御できます。

import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
import { useState } from 'react'

function ControlledTabs() {
  const [selectedIndex, setSelectedIndex] = useState(0)

  return (
    <TabGroup selectedIndex={selectedIndex} onChange={setSelectedIndex}>
      <TabList>
        <Tab>Tab 1</Tab>
        <Tab>Tab 2</Tab>
        <Tab>Tab 3</Tab>
      </TabList>
      <TabPanels>
        <TabPanel>Content 1</TabPanel>
        <TabPanel>Content 2</TabPanel>
        <TabPanel>Content 3</TabPanel>
      </TabPanels>
      <button onClick={() => setSelectedIndex(0)}>Reset to first tab</button>
    </TabGroup>
  )
}
ポータルレンダリング

DOM 階層の外側にコンポーネントをレンダリングします。

import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import { createPortal } from 'react-dom'

function PortalMenu() {
  return (
    <Menu>
      <MenuButton>Options</MenuButton>
      {createPortal(
        <MenuItems>
          <MenuItem>
            <button>Edit</button>
          </MenuItem>
          <MenuItem>
            <button>Delete</button>
          </MenuItem>
        </MenuItems>,
        document.body
      )}
    </Menu>
  )
}
フォームとの統合

React Hook Form などのフォームライブラリと一緒に使用できます。

import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
import { useForm, Controller } from 'react-hook-form'

function FormExample() {
  const { control, handleSubmit } = useForm()

  const onSubmit = (data) => {
    console.log(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="country"
        control={control}
        rules={{ required: true }}
        render={({ field }) => (
          <Listbox {...field}>
            <ListboxButton>Select country</ListboxButton>
            <ListboxOptions>
              <ListboxOption value="us">United States</ListboxOption>
              <ListboxOption value="ca">Canada</ListboxOption>
              <ListboxOption value="mx">Mexico</ListboxOption>
            </ListboxOptions>
          </Listbox>
        )}
      />
      <button type="submit">Submit</button>
    </form>
  )
}

Vue のサポート

Headless UI は Vue 3 でも同様に動作します。

<script setup>
import { ref } from 'vue'
import {
  Listbox,
  ListboxButton,
  ListboxOptions,
  ListboxOption,
} from '@headlessui/vue'

const people = [
  { id: 1, name: 'Wade Cooper' },
  { id: 2, name: 'Arlene Mccoy' },
  { id: 3, name: 'Devon Webb' },
]

const selectedPerson = ref(people[0])
</script>

<template>
  <Listbox v-model="selectedPerson">
    <ListboxButton>{{ selectedPerson.name }}</ListboxButton>
    <ListboxOptions>
      <ListboxOption
        v-for="person in people"
        :key="person.id"
        :value="person"
        v-slot="{ active, selected }"
      >
        <li :class="{ 'bg-blue-500': active }">
          {{ selected ? '✓' : '' }} {{ person.name }}
        </li>
      </ListboxOption>
    </ListboxOptions>
  </Listbox>
</template>

TypeScript のサポート

TypeScript による完全な型安全性を提供します。

import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'

interface User {
  id: number
  name: string
  role: 'admin' | 'user'
}

interface UserMenuProps {
  user: User
  onEdit: (user: User) => void
  onDelete: (userId: number) => void
}

function UserMenu({ user, onEdit, onDelete }: UserMenuProps) {
  return (
    <Menu as="div" className="relative">
      <MenuButton className="btn">{user.name}</MenuButton>
      <MenuItems className="menu">
        <MenuItem>
          {({ focus }) => (
            <button
              className={focus ? 'bg-blue-500' : ''}
              onClick={() => onEdit(user)}
            >
              Edit
            </button>
          )}
        </MenuItem>
        <MenuItem>
          {({ focus }) => (
            <button
              className={focus ? 'bg-red-500' : ''}
              onClick={() => onDelete(user.id)}
            >
              Delete
            </button>
          )}
        </MenuItem>
      </MenuItems>
    </Menu>
  )
}

Tailwind CSS との統合

Headless UI は Tailwind CSS 向けに設計されています。

状態を表すデータ属性

Headless UI v2 では、状態に応じたスタイル設定にデータ属性を使用します。

// Modern approach with data attributes
<MenuButton className="data-[active]:bg-blue-500 data-[disabled]:opacity-50">
  Options
</MenuButton>

// Available states
// data-[active] - Element is active/focused
// data-[selected] - Element is selected
// data-[disabled] - Element is disabled
// data-[open] - Element/panel is open
// data-[focus] - Element has focus
// data-[checked] - Element is checked (Switch)
Tailwind プラグイン

Headless UI の状態に合わせて Tailwind を設定します。

// tailwind.config.js
module.exports = {
  plugins: [
    require('@headlessui/tailwindcss')
  ]
}

次に、モディファイアを使用します。

<MenuButton className="ui-active:bg-blue-500 ui-disabled:opacity-50">
  Options
</MenuButton>

アクセシビリティ機能

ARIA 属性

すべての ARIA 属性が自動的に管理されます。

  • 開閉ボタンの aria-expanded
  • タブ/オプション要素の aria-selected
  • スイッチの aria-checked
  • 関連付けの aria-labelledby
  • 説明の aria-describedby
  • role 属性(menu、listbox、dialog など)
キーボード操作

キーボード操作を完全にサポートしています。

  • 矢印キー:オプション/タブ間を移動します
  • Enter/Space:選択/有効化します
  • Escape:メニュー/ダイアログを閉じます
  • Tab:フォーカスを管理します
  • Home/End:最初/最後の項目に移動します(該当する場合)
  • タイプアヘッド:入力して検索します
フォーカス管理

フォーカスは自動的に処理されます。

  • 閉じたときにフォーカスを戻します(Dialog、Menu、Popover)
  • モーダル内でフォーカスをトラップします
  • 初期フォーカスを制御します
  • 開いたときにフォーカス対象の要素へ移動します
スクリーンリーダーのサポート

以下の環境でテスト済みです。

  • NVDA(Windows)
  • JAWS(Windows)
  • VoiceOver(macOS、iOS)
  • TalkBack(Android)

サーバーサイドレンダリング

Next.js、Remix、その他の SSR フレームワークと完全に互換性があります。

// app/page.tsx (Next.js 13+)
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'

export default function Page() {
  return (
    <Menu>
      <MenuButton>Options</MenuButton>
      <MenuItems>
        <MenuItem>
          <button>Edit</button>
        </MenuItem>
      </MenuItems>
    </Menu>
  )
}

特別な設定は不要です。コンポーネントはサーバーとクライアントで同じように動作します。

ベストプラクティス

  1. 必ずラベルを指定する - 非表示のラベルには sr-only クラスを使用します
  2. すべての状態をスタイル設定する - アクティブ、選択済み、無効の状態には data 属性を使用します
  3. キーボード操作をテストする - Tab、矢印キー、Enter、Escape が機能することを確認します
  4. セマンティック HTML を使用する - コンポーネントに適切な要素をレンダリングさせます
  5. フォーカスインジケーターを用意する - キーボードユーザー向けに、常にフォーカス状態を表示します
  6. スクリーンリーダーでテストする - 読み上げが正しいことを確認します
  7. 読み込み状態に対応する - 非同期処理中に適切な UI を表示します
  8. 必要に応じて制御モードを使用する - 複雑な状態管理に使用します
  9. Transition と組み合わせる - 開閉時に滑らかなアニメーションを追加します
  10. オーバーレイをポータルにする - z-index の問題を避けるため、メニュー/ダイアログにはポータルを使用します

よくある問題

❌ 状態に対応する Tailwind クラスが不足している:

// WRONG - no visual feedback
<MenuButton>Options</MenuButton>

// CORRECT
<MenuButton className="data-[active]:bg-blue-500 data-[open]:bg-blue-600">
  Options
</MenuButton>

❌ レンダープロップで Fragment を使用していない:

// WRONG - adds extra div
<Transition show={isOpen}>
  <div>Content</div>
</Transition>

// CORRECT
<Transition show={isOpen} as={Fragment}>
  <div>Content</div>
</Transition>

❌ 制御された状態への対応を忘れている:

// WRONG - onChange does nothing
<Listbox value={selected}>
  <ListboxOptions>...</ListboxOptions>
</Listbox>

// CORRECT
<Listbox value={selected} onChange={setSelected}>
  <ListboxOptions>...</ListboxOptions>
</Listbox>

リソース

まとめ

  • Headless UI は、スタイルが設定されていないアクセシブルなコンポーネントプリミティブを提供します
  • ランタイム CSS はゼロ - Tailwind またはカスタム CSS でスタイルを適用できます
  • 完全なアクセシビリティ - ARIA、キーボード操作、スクリーンリーダーのサポートを標準搭載しています
  • React と Vue - 両フレームワークで同一の API を使用できます
  • TypeScript - 型定義がすべて含まれています
  • レンダープロップ - コンポーネントの状態にアクセスしてカスタムレンダリングを行えます
  • SSR 互換 - Next.js、Remix、Nuxt で動作します
  • 最適な用途 - カスタムデザインシステム、Tailwind CSS との統合、アクセシブルなコンポーネント

コメント