みなさん、コンポーネントライブラリは何を使っていますでしょうか。
筆者はVueを仕事で使うことが多いのですが、最近はTailwindCSSを使ってスタイリングをしています。Vue界隈ではVuetifyというマテリアルデザインのコンポーネントライブラリが一世を風靡していたのですが、やはりある程度デザイン方針が固定されたコンポーネントライブラリでは、柔軟なUI/UXの調整に不自由してしまうケースもありました。そのためスタイリングの拡張性を確保しつつ、AIフレンドリーでもあるTailwindCSSを使うことが増えたのですが、とはいえ、リッチなコンポーネントをスクラッチで作成するのも骨が折れる仕事です(というか、それが嫌だからコンポーネントライブラリを使っていたはず)。
そういう思いが他の人も感じていたのかどうかは定かではありませんが、昨今はshadcn/uiというものが流行っています。
The Foundation for your Design System - shadcn/ui
shadcn/uiはもともとReact向けにリリースされたものですが、同じ思想のもと、Vue向けやSvelte向けのものがリリースされています。 shadcnの特徴は、「ヘッドレスUI」かつ「TailwindCSSベースである」ことです。もちろんプリセットされたデザインはありますが、従来のコンポーネントライブラリと違い、見た目を決定する部分のソースコードを直接生成するため、利用者側で自由に調整することが可能なのです(もちろん、TailwindCSSのカラースキーマもいじれます!)。これにより、最初は手間を省いてコンポーネントを使えるようになり、必要に応じて自由にデザインを修正・拡張することが可能になったのです。
この記事で分かること
- ヘッドレスUIとは何か、その本質的な考え方
- 従来のコンポーネントライブラリとの違い
- ヘッドレスUIが解決する具体的な課題
- ヘッドレスUIを使うべきケースと判断基準
ところで、ヘッドレスUIってなんやねん
こう書くといいことずくめに聞こえますが、前述した「ヘッドレスUI」とはそもそもなんでしょうか?これの理解無しに、手放しでshadcn(や、他のヘッドレスUI)を使っても良いものでしょうか?
筆者は、ヘッドレスUIというものは2年ほど前に知りました。プライベートでTailwindCSSの公式ライブラリ(今ではTailwind Plusといいます)を購入して使ってみたときに、まさしくヘッドレスUIを使っていたからです。ちなみにTailwindCSSの開発元であるTailwind Labs自体がHeadless UIというライブラリを開発しており、Tailwind Plusもそれを採用していました。
ヘッドレスUIを知ったと言っても、実際はコピペして色や余白を微調整するぐらいしか活用していなかったので、正直なところ「ヘッドレスであること」にそこまで注目していませんでした。もちろん、従来のコンポーネントライブラリによっては、そもそもそういった色や余白の調整すらできないものも多かったので、それらに比べると柔軟性はあるなぁ、ぐらいの認識でした。
今まで惰性で使い続けていましたが、いよいよヘッドレスUIが主流になってきた今、ちゃんとその利点を知っておかなければまずい、と思って整理することにしました。
コンポーネントの構成要素を考えてみる
「ヘッドレスUI(Headless UI)」 とは、見た目(デザイン)を持たないUIコンポーネントのことを指します。
つまり、UIのロジックや振る舞いだけを提供し、スタイルやマークアップは開発者が自由に決められる仕組みです。
多くのドキュメントで同様の説明がされていますが、これを見て具体的にどのようなイメージか掴めるでしょうか?筆者は無理でした。 なぜ理解が難しいのかというと、フロントエンドでは「ロジックとデザインはセットで扱う」ことがよくあるからです。フロントエンドの特性上、データをどのように(色・位置・大きさなど)表示するかが本領であり、それにはロジックとデザインの両方向からのアプローチが必要です。そのため、そのうちのロジック部分を切り離す、というのがパッと理解しにくいのです。
ここの理解を深めるために、自分なりに「フロントエンドのコンポーネントの構成要素」を分解して整理してみます。
-
状態
- 変数やPropsといったいわゆるデータ部分。基本的に、フロントエンドはこの状態を可視化する責務を負う。
-
ロジック
- 状態に影響を与える処理。ユーザ操作との橋渡しを担う。
-
デザイン
- 状態を可視化するための手段。
フレームワークによって書き方に差はあれど、フロントエンドのコーディングは上記の3つの要素を書き上げる行為と認識しています。 これらをセットにしてカプセル化したものを一言で「コンポーネント」と呼び、コンポーネントを作成することで再利用性や保守性、可読性を高めることができるのです。 ここで重要なのは、経験として「コンポーネント=状態+ロジック+デザインであり、コンポーネント単位で再利用する」ということがスタンダードになっていることです。
状態・ロジック・デザインの具体例
シンプルな例を考えたいので、「ボタンを押すとモーダルダイアログが開く」ような例を考えます。 フレームワークはVueとしますが、根本的な考え方は他のフレームワークでも同じです。 ダイアログの表示・非表示の切り替えには簡単なアニメーションをつけるものとします。 ヘッドレスUIを実現する方法はいくつかありますが、今回はTailwind謹製のHeadlessUIを利用します。
自分で実装する場合
<script setup lang="ts">
defineProps<{
title: string;
}>();
const open = defineModel<boolean>({
default: true,
required: true,
});
const onClose = () => {
open.value = false;
};
</script>
<template>
<!-- 背景オーバーレイ -->
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="open" class="fixed inset-0 bg-black/50 z-40" />
</Transition>
<!-- モーダルコンテナ -->
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="open" class="fixed inset-0 flex items-center justify-center w-full z-50">
<div class="bg-white w-full rounded-lg max-w-md">
<div class="flex items-center justify-between p-2 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900">{{ title }}</h2>
<button class="text-gray-400 hover:text-gray-600" @click="onClose">
<span class="text-2xl leading-none">×</span>
</button>
</div>
<div class="p-6">
<slot />
</div>
<div class="flex items-center justify-end p-2 border-t border-gray-200">
<button
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
@click="onClose"
>
閉じる
</button>
</div>
</div>
</div>
</Transition>
</template>
ヘッドレスUIを使う場合
<script setup lang="ts">
import { TransitionRoot, TransitionChild, Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
defineProps<{
title: string;
}>();
const isOpen = defineModel<boolean>();
const closeModal = () => {
isOpen.value = false;
};
</script>
<template>
<TransitionRoot appear :show="isOpen" as="template">
<Dialog as="div" @close="closeModal" class="relative z-10">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/25" />
</TransitionChild>
<div class="fixed inset-0 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel
class="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all"
>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-gray-900">
{{ title }}
</DialogTitle>
<div class="mt-2">
<slot />
</div>
<div class="mt-4">
<button
type="button"
class="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"
@click="closeModal"
>
閉じる
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
比べてみて
どうでしょうか?正直そこまで記述量に差はないと思いますか?
この点が、筆者が(当初)ヘッドレスUIのメリットを理解していなかった点になります。コンポーネントライブラリを使うモチベーションの一つに「コーディングの量を減らして楽をしたい」というものがあるはずなのに、それが達成できていないように見えていたからでした。
特に、この「コーディング量」は、具体的に「見た目に関するコーディング」を指すことが多いです(アプリ固有の業務ロジックは自分で書かざるをえないので)。それなのに、(↑のコードを見てもらうとわかるように)ヘッドレスUI版でも各要素に多くのクラスをアタッチしており、「結局自分で書かないといけない」という事態になっています。
そもそも本当に同じ成果物になるのか?
先ほどコーディング量の比較を行いましたが、実はこの比較は適切ではありません。というのも、厳密には成果物に違いがあるからです。
ヘッドレスUI版の方は、<DialogTitle>を使っている場合、ダイアログ自身に対してaria-labelledby属性がつきます。この属性を付与することで、ダイアログのアクセシビリティが向上します。
aria-labelledby属性についてはaria-labelledby - ARIA - MDN Web Docsを参照ください。
他にも、ヘッドレスUI版には細かい操作が実装されています。例えば、ダイアログの背景(↑のコードでは薄暗くなる部分)をクリックするとダイアログが閉じたり、Escキーを押すことでもダイアログが閉じるようになっています。
ライブラリに何を任せるか
前述の例では、Headless UIを使うことで下記の機能が自動的に実装されていたことになります。
- 適切な
aria属性を付与する - ダイアログの背景をクリックするとダイアログを閉じる
- キーボード操作を受け付ける
もちろん、これらが必要な機能なのであれば、自前で実装すれば済む話です。しかし、ここから「ヘッドレスUI」に何を任せることができるのか、を考えることができます。
最初に述べた通り、ヘッドレスUIは「ロジックやふるまいのみを提供するもの」でした。このロジックやふるまい、というところを見た目と可分にしているところに大きな意味があります。
コンポーネントライブラリを使わない場合、ロジックも見た目も全てを自分でコーディングする必要があります。また、従来のコンポーネントライブラリを使う場合は、そのうち見た目に関わる部分をライブラリに任せることになります。
これによる弊害はなんでしょうか。
まず思いつくのは「デザインのカスタマイズ」です。コンポーネントライブラリは、そのライブラリの中では一貫したデザイン思想を持ち、それに従うようにコンポーネントがデザインされています。ただし、細かい点で開発者の思惑と違う場合は、開発者はその部分を修正したくなります。例えばダイアログであれば、表示する位置やアニメーション、背景色などが考えられます。このとき、どれほど修正できるか、というのはコンポーネントライブラリによって違いますが、大きく見た目を変更できるものは少ないでしょう。
この課題は、よくよく考えれば見当違いな気もします。というのも、コンポーネントライブラリのメリットとしても、「デザインの一貫性」は挙げられるはずで、開発者がそのデザインが気に食わないのであれば最初から使わなければいいのです。とは言いつつ、それでもまずはコンポーネントライブラリを使うことを検討する人が多いでしょう。
このジレンマのようなものは、コンポーネントに期待するものが「状態」+「ロジック」+「デザイン」であることを認識し、そこに「ヘッドレスUI」という考えを取り入れることで解消できます。
開発者がコンポーネントライブラリにやってほしいことは、
- 「状態」+「ロジック」+「デザイン」のコーディング量削減
- 「状態」+「ロジック」のコーディング量削減
のどちらでしょうか?前者なのであれば従来のコンポーネントでよいですが、後者なのであれば、「便利なロジック部分だけ適用し、デザイン部分は自由にできる」ライブラリが欲しくなります。それに応えるのがヘッドレスUIというわけです。
これを踏まえて振り返ると、aria属性を付与したりキーボード操作を受け付けたり、といった状態やロジックの部分はライブラリで実装してもらい、デザインの部分を開発者が定義する、という明確な責務の分割ができるため、全て自前で実装するよりもヘッドレスUIに頼った方が開発効率が向上するのです。
ヘッドレスUIにやってもらうのがロジックの面倒を見てもらうことであるからこそ、ヘッドレスUIの使用/不使用でコード量があまり変わっていないように見えるのです。デザインのためのコーディングはどうしてもそれなりの記述量を必要とするため、その部分に差異がなければ全体としても差が無いように感じてしまうのです。
本当に見た目は提供しないのか?
違う例を見てみます。今度はReka UIという別のヘッドレスUIライブラリを使用します。
今回作成するのはセレクト部品です。ボタンをクリックすると選択肢がポップオーバーで表示され、選択できるようになる部品です。
この部品をReka UIを使って実装すると以下のようになります(公式サイトから引用)。
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import {
SelectContent,
SelectGroup,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectPortal,
SelectRoot,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
import { ref } from 'vue'
const fruit = ref()
const options = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']
const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']
</script>
<template>
<SelectRoot v-model="fruit">
<SelectTrigger
class="inline-flex min-w-[160px] items-center justify-between rounded-lg px-[15px] text-xs leading-none h-[35px] gap-[5px] bg-white text-grass11 hover:bg-stone-50 border shadow-sm focus:shadow-[0_0_0_2px] focus:shadow-black data-[placeholder]:text-green9 outline-none"
aria-label="Customise options"
>
<SelectValue placeholder="Select a fruit..." />
<Icon
icon="radix-icons:chevron-down"
class="h-3.5 w-3.5"
/>
</SelectTrigger>
<SelectPortal>
<SelectContent
class="min-w-[160px] bg-white rounded-lg border shadow-sm will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade z-[100]"
:side-offset="5"
>
<SelectScrollUpButton class="flex items-center justify-center h-[25px] bg-white text-violet11 cursor-default">
<Icon icon="radix-icons:chevron-up" />
</SelectScrollUpButton>
<SelectViewport class="p-[5px]">
<SelectLabel class="px-[25px] text-xs leading-[25px] text-mauve11">
Fruits
</SelectLabel>
<SelectGroup>
<SelectItem
v-for="(option, index) in options"
:key="index"
class="text-xs leading-none text-grass11 rounded-[3px] flex items-center h-[25px] pr-[35px] pl-[25px] relative select-none data-[disabled]:text-mauve8 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-green9 data-[highlighted]:text-green1"
:value="option"
>
<SelectItemIndicator class="absolute left-0 w-[25px] inline-flex items-center justify-center">
<Icon icon="radix-icons:check" />
</SelectItemIndicator>
<SelectItemText>
{{ option }}
</SelectItemText>
</SelectItem>
</SelectGroup>
<SelectSeparator class="h-[1px] bg-green6 m-[5px]" />
<SelectLabel class="px-[25px] text-xs leading-[25px] text-mauve11">
Vegetables
</SelectLabel>
<SelectGroup>
<SelectItem
v-for="(option, index) in vegetables"
:key="index"
class="text-xs leading-none text-grass11 rounded-[3px] flex items-center h-[25px] pr-[35px] pl-[25px] relative select-none data-[disabled]:text-mauve8 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-green9 data-[highlighted]:text-green1"
:value="option"
:disabled="option === 'Courgette'"
>
<SelectItemIndicator class="absolute left-0 w-[25px] inline-flex items-center justify-center">
<Icon icon="radix-icons:check" />
</SelectItemIndicator>
<SelectItemText>
{{ option }}
</SelectItemText>
</SelectItem>
</SelectGroup>
</SelectViewport>
<SelectScrollDownButton class="flex items-center justify-center h-[25px] bg-white text-violet11 cursor-default">
<Icon icon="radix-icons:chevron-down" />
</SelectScrollDownButton>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
ものすごいクラスの数なので、全体像が掴みにくいですね。同じページから、アナトミー(構造)のみを記述したものがあったので、それを見てみましょう。
<script setup lang="ts">
import {
SelectContent,
SelectGroup,
SelectIcon,
SelectItem,
SelectItemIndicator,
SelectLabel,
SelectPortal,
SelectRoot,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
</script>
<template>
<SelectRoot>
<SelectTrigger>
<SelectValue />
<SelectIcon />
</SelectTrigger>
<SelectPortal>
<SelectContent>
<SelectScrollUpButton />
<SelectViewport>
<SelectItem>
<SelectItemText />
<SelectItemIndicator />
</SelectItem>
<SelectGroup>
<SelectLabel />
<SelectItem>
<SelectItemText />
<SelectItemIndicator />
</SelectItem>
</SelectGroup>
<SelectSeparator />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
どうでしょうか?コンポーネント名の分かりやすさも相まって、見た目の部分が論理的に構造化されていることが見て取れます(この論理的な構造の話は後でも取り上げます)。選択肢を表示するためのボタンをどこに書けばいいのか、選択肢の見た目をどこに書けばいいのかが何となくわかるのでは無いでしょうか。
このコンポーネントを書く上で、「ボタンを押したら選択肢を表示する」「選択肢の中から選んだものをデータにバインディングする」といったロジックを書く必要は全くありません(しつこいようですが、ロジックはヘッドレスUIに任せられるので)。一方で、具体的にどんな選択肢の見た目にするのか、というところは開発者に委ねられています。それぞれの部品(<SelectItem>など)にclassでデザインを当てていくことで、実際の見た目が出来上がっていくのです。
もっというと、<SelectTrigger>はボタンのようにレンダリングされますが、@click="..."のように、わざわざ選択肢を表示するような処理を書く必要はありません。そのあたりは<SelectRoot>がよしなにハンドリングしてくれます。まさに、デザインだけ定義してロジックは任せることができる例です。
ちなみに、このコンポーネントでは、トリガーをクリックすると選択肢一覧がトリガーのすぐ下に表示されます。そのため、「トリガー」と「選択肢一覧のポップオーバー」の位置関係についての設定がどこかに必要そうですが、それをTailwindCSSで書いている箇所が見つかりません。<SelectTrigger>と<SelectPortal>の位置関係は、あらかじめ<SelectRoot>が定義してくれているので、開発者が意識する必要がないのです。ここから分かるのは、ヘッドレスUIは全くデザインにノータッチ、というわけではなく、レイアウトなどについては既定のものを提供してくれることもある、というところです(ライブラリによりますが)。
この部分も筆者が直感的に理解しがたかったところでした。「状態とロジックのみ」でどうやって開発者のデザインと連携するのか?と思っていましたが、状態・ロジックとデザインの最低限の橋渡しまではライブラリでやってくれていたのでした。
見た目と実際の乖離
筆者がヘッドレスUIを理解しにくいと感じたもう一つの点は、見た目の構造と実際のレンダリング結果の乖離でした。先のアナトミーから分かる通り、ヘッドレスUIは(ライブラリによって差異はあれど)論理的構造を意識したインターフェースを提供することが一般的です。従来のコンポーネントライブラリでは、開発者側が要素やクラスを書くことは無く、差し込みたいものはprops経由で渡すデータぐらいしかありませんでした。ヘッドレスUIでは要素やクラスを受け取るときに、単にまるごと<slot>で受け取るだけでなく、そのコンポーネント内での役割ごとにさらにネストされたコンポーネントで論理的に分割しています。開発者は、「この要素はこういう役割を持たせたい」という意識のもとヘッドレスUIのコンポーネントに渡します。あくまで役割ベースなので、実際にレンダリングされるときは、ソースコード上の見た目とは必ずしも一対一にマッピングしません。
例えば、上記の<SelectRoot> ですが、<SelectTrigger>と<SelectPortal>は必ずしも兄弟関係にある必要はなく、同じ<SelectRoot>内であれば基本的に自由な場所に配置できます。従来のコンポーネントの考え方では、コンポーネントのネスト関係がそのままレンダリング時の要素のネスト関係に反映されるので不思議な感じがします。ヘッドレスUIでは、その連携部分こそがロジックとして提供すべき重要な部分であり、開発者がデザインに制約がないようにヘッドレスUIが吸収してくれるのです。
ヘッドレスUIは「状態とロジックだけ」というのはちょっと違う
結果として、ヘッドレスUIの目標は「状態とロジックだけ提供する」のではなく、「開発者がデザインのみに注力できるようにする」であることを理解しました。これは、まったくデザインが自由である、というのとは違います。まったくの自由であれば、かえって開発者はフルスクラッチで位置関係などを書く必要が生じます。そうではなく、ある程度のデザインの型を提供しつつも、開発者が柔軟に変更できる余地を最大限にするところがヘッドレスUIの魅力なのです。
単にコーディング量を減らしたい、という目的では従来のコンポーネントライブラリが役立つでしょう。しかしながら、デザイン面におけるカスタマイズが全く不要である、と言い切れない限り、筆者はヘッドレスUIを利用することを推奨します。
まとめ
本記事では、ヘッドレスUIの本質について深掘りしてきました。最後に重要なポイントを整理します。
ヘッドレスUIの本質
- 「状態」と「ロジック」を提供し、デザインは開発者に委ねるというアプローチ
- 単に見た目を持たないだけでなく、「開発者がデザインに注力できる」ことが真の目的
- レイアウトなど最低限の橋渡しはライブラリが担当し、細部は開発者が自由にカスタマイズ可能
従来のコンポーネントライブラリとの違い
- 従来型: ロジック+デザインをセットで提供(カスタマイズ性は低い)
- ヘッドレスUI: ロジックのみ提供し、デザインは完全に開発者の手に(カスタマイズ性は高い)
ヘッドレスUIを使うべきケース
以下のような場合にヘッドレスUIの採用を検討すべき:
-
デザインのカスタマイズ性が重要な場合
- 独自のデザインシステムを持っている
- ブランドガイドラインに沿った細かい調整が必要
-
ロジックの複雑性は高いが、見た目は独自にしたい場合
- モーダル、ドロップダウン、タブなどの複雑な UI コンポーネント
- 状態管理やイベントハンドリングはライブラリに任せたい
-
TailwindCSSなどのユーティリティファーストCSSを使っている場合
- デザインの記述方法と親和性が高い
- クラスベースでのカスタマイズが容易