Laravelのアプリを作っていて、「ボタンの角を少し丸くしたい」という一言から全コンポーネントを触る羽目になった経験は珍しくない。色を変えたいだけなのに、Bladeのあちこちに散らばったrounded-lgやbg-blue-600を探して回ることになる。Pinion UI(sparrowhawk-labs/pinion-ui)は、その往復を<html>タグの属性2つに畳み込もうとしているBladeコンポーネント集だ。
data-tuneとdata-themeの2つの属性だけ。掲載にあたり1000px幅・30fpsへ再エンコードした- 何ができるか:`<html data-theme="pinion" data-tune="soft">`の2属性で、Bladeを触らずにアプリ全体の色と形を差し替える
- 実体:Laravel向けBladeコンポーネント集。Tailwind v4 + daisyUI v5 + Alpine.js。MITライセンス、PHP 8.2以上・Laravel 11/12/13対応
- 規模:GitHubスター24・フォーク2、Packagist累計93ダウンロード。最新はv0.10.4(2026年8月1日)で、まだ0.x系
- 軸は2つではなく3つ:`data-tune-strength`で効き幅を0.45〜1.90倍に調整できる(後述の実測値を参照)
- 注意点:READMEに載るtune名のうち`playful`・`elegant`・`bold`は実装から消えており、指定しても無反応(実測で確認)
公式READMEの和訳ではなく、v0.10.4のソースとCSSを実際に読み、ブラウザ上でトークン値を計測した結果をもとに書いている。READMEの数字と名前が実装に追いついていない箇所を具体的に指摘し、正しい値を一覧で示す。
デザインシステムそのものの考え方や構成要素を先に押さえたい場合は、デザインシステムとは?仕組み・構成要素・有名事例をエンジニア向けに整理する【2026年版】を読んでおくと、Pinion UIがどのレイヤーを既製で提供しているのかが掴みやすい。
Pinion UIとは——色と形を別々のHTML属性に分けた設計
Pinion UIは、Sparrowhawk Labs(開発者はAkihiko Takai氏)が公開しているLaravel向けのBlade UIコンポーネント集だ。土台はTailwind CSS v4・daisyUI v5・Alpine.jsで、composer.jsonの宣言によればPHP 8.2以上、illuminate/supportの^11.0/^12.0/^13.0(Laravel 11/12/13相当)を要求する。ライセンスはMIT。
この種のライブラリは数多いが、Pinion UIが他と分かれるのは見た目を決める責務を、独立した属性へ水平に切り分けた点にある。
・data-theme — 色だけを決める。背景・文字色・primary/secondaryなどのセマンティックカラーがここで切り替わる
・data-tune — 色以外を決める。角丸・余白・線幅・行間・字送り・フォント。「形と間」の担当
・data-tune-strength — tuneの効き幅を5段階でスケールする。READMEの見出しには出てこない3本目の軸
・Bladeのprops — color/size/appearanceなど、個々の部品に対する指定
肝心なのは、色を足しても形のコードを触らないし、形を足しても色の定義を触らないという点だ。テーマの定義はsrc/resources/themes/lineup.jsonという1つのJSONに集約され、そこからpackages/pinion-ui-css/harness/gen-themes.mjsがCSSを生成する。tuneの定義はsrc/resources/css/tune.cssに閉じている。両者は互いを参照しない。tune.cssのコメントにも「tuneトークンは全テーマで意図的に同一」(invariant 5)と明記されている。
もう1つ、実務で効いてくる設計がある。クラス文字列をBladeに散らさず、InputComposerやSelectComposerといった型付きのPHPクラス(Composeレイヤー)に集約していることだ。これによりバリアントやサイズの組み合わせがテスト可能になる代わりに、後述するTailwindのスキャン設定に一手間が必要になる。
何ができるか:属性2つでアプリ全体の色と形を差し替えられる。何を解決するか:「角丸を変えたい」が全Blade横断の一括置換になる問題。何を代替できるか:Bladeでコンポーネントを自作していた層、およびdaisyUIを素で使って独自のテーマ管理を書いていた層。
Pinion UIのインストールと初期設定——ui:installが何を書き換えるか
導入は2コマンドだ。
composer require sparrowhawk-labs/pinion-ui
php artisan ui:install --ai
1行目でsparrowhawk-labs/pinion-iconsも必須依存として同時に入る。こちらはアイコンパッケージで、Pinion UI側から外せない構成になっている。リポジトリを取得して実際に数えたところ、resources/icons配下のSVGはSolarが7,404個(READMEの「7,404 Solar Icons」と一致)、Fluent Emojiが1,247個、Pixelarticonsが495個、Solarの追加分が24個で、合計9,170個だった。
2行目のui:installは、src/Commands/UiInstall.phpを読むと分かるとおり、単なるファイルコピーではなくプロジェクト側の設定を複数書き換える。主なものは次のとおり。
・npm依存の追加(daisyui ^5/alpinejs ^3/@alpinejs/focus ^3。focusプラグインは<x-sidebar>などのフォーカストラップが要求する)
・resources/css/app.cssにプリセットの@importを1行追加
・resources/js/app.jsにAlpineとfocusプラグインの登録を追加
・レイアウトの<html>タグにdata-themeを差し込む(--skip-layoutで抑止可)
・AGENTS.md/CLAUDE.md/.claude/settings.jsonへの書き込み(後述)
CSS側で追加されるのは、実質この1行だ。
@import "tailwindcss";
@import "../../vendor/sparrowhawk-labs/pinion-ui/src/resources/css/pinion-ui.css";
この@importが、daisyUIのロード・テーマ定義・tuneトークン・そしてTailwindの@sourceグロブをまとめて配線する。@sourceが必要なのは、前述のComposeレイヤーがクラス文字列をPHP側に持っているためだ。Tailwind v4の既定スキャンは*.blade.phpや*.jsしか見ないので、これが無いとPHP内のクラスがビルド結果から黙って消える。
@plugin "daisyui";を書き足さないプリセットはdaisyUIを
themes: false+コンポーネント除外リスト付きで読み込んでいる。ここに素のdaisyUIプラグイン行を足すと、除外していたコンポーネントクラスと組み込みテーマが全部復活してしまう。既存の記述がある場合はui:installが削除する。導入後はnpm install && npm run buildを回し、レイアウトに属性を置けば動き始める。
<html data-theme="pinion" data-tune="default">
コンポーネントは匿名コンポーネントとして登録されるため、接頭辞なしで<x-button color="primary">保存</x-button>のように書ける。名前の衝突を避けたい場合は<x-pn::button>という完全修飾形も使える。なおui:installは既存のresources/views/componentsを走査して名前衝突を事前に検出する。
tuneを実測する——同じマークアップが何段階変わるのか
「858通りの見た目」といった数字は魅力的だが、実際に何がどれだけ動くのかは数字を見ないと分からない。そこでtune.cssを実ブラウザに読み込ませ、getComputedStyleで解決後の値を計測した。
主要なトークンの実測値は次のとおり。いずれもdata-tuneだけを差し替え、他の条件は固定して測っている。
| tune | --radius-box |
線幅 | 行間 | 字送り |
|---|---|---|---|---|
brutal |
0px | 2.5px | 25.6px | -0.16px |
editorial |
2px | 1px | 27.2px | normal |
minimal |
6px | 1px | 26.4px | -0.32px |
default |
8px | 1px | 25.6px | -0.16px |
luxury |
12px | 1px | 26.4px | -0.24px |
soft |
18px | 1px | 26.4px | -0.08px |
brutalだけ線幅が2.5倍になり角丸がゼロになる、editorialは行間が最も広く字送りの詰めを外す、といった具合に、プリセットごとに変わるトークンの組み合わせが違う。角丸だけを一律にスケールしているわけではない。
3本目の軸:data-tune-strength
READMEの機能一覧には出てこないが、tune.cssには効き幅を制御する係数--tune-kがあり、data-tune-strength属性で5段階に切り替わる。ソース上の定義はxsが0.45、smが0.70、mdが1、lgが1.45、xlが1.90。同じsoftに対してstrengthだけを変えて計測すると、角丸は次のように動いた。
data-tune-strength |
係数 | softでの--radius-box |
|---|---|---|
xs |
0.45 | 12.5px |
sm |
0.70 | 15px |
md(既定) |
1 | 18px |
lg |
1.45 | 22.5px |
xl |
1.90 | 27px |
トークンの解決式はbase + delta × kで、softの場合はベース0.5rem(8px)にデルタ0.625rem(10px)が係数倍で乗る。xsなら8 + 10×0.45 = 12.5pxで、実測値と一致する。つまり「属性2つ」という説明は入り口としては正しいが、実際に触れる軸は3つある。組み合わせ数を数え直すなら、テーマ39×tune 11×light/dark 2という858通りの、さらに外側にstrengthの5段が乗ることになる。
計測は
tune.css単体をブラウザに読み込んで行った。実際のアプリではTailwindとテーマCSSが同時に載るため、コンポーネント単位の最終的な見え方はこれと完全に同じとは限らない。ここで示しているのはtuneトークンそのものの解決値であり、プリセット間の相対的な差を確認する目的の数値である。READMEと実装のズレ——効かないtune名3つとコンポーネント数
ここが、公式ドキュメントをそのまま写すと踏む落とし穴だ。
READMEの機能一覧は、11個のtuneプリセットをdefault・minimal・sharp・soft・playful・corporate・brutal・elegant・bold・pixel・techと列挙している。しかしtune.cssを[data-tune="…"]で検索すると、実際に定義されているのはdefault・minimal・sharp・soft・draft・corporate・brutal・editorial・luxury・pixel・techだった。個数は同じ11個だが、3つの名前が食い違っている。
理由はCHANGELOGに書かれている。v0.4.4のTune v2リリースでplayful→soft、elegant→editorial、bold(およびmonumental)→luxury、sketch→draft、terminal→techという改名が行われた。実装は追随したが、READMEの機能一覧は旧名のまま取り残された。v0.6.0からv0.10.4、そして現在のmainに至るまで、この食い違いは一貫して残っている。
指定するとどうなるかを計測した
問題は、間違った名前を指定してもエラーが出ないことだ。実際にどう振る舞うのかを、無意味な文字列(zzz-garbage)を対照群として並べて計測した。以下は計測した4トークンの範囲での比較である。
指定したdata-tune |
--radius-box |
線幅 | 行間 | 字送り |
|---|---|---|---|---|
soft(実在) |
18px | 1px | 26.4px | -0.08px |
playful(README) |
8px | 1px | 25.6px | -0.16px |
elegant(README) |
8px | 1px | 25.6px | -0.16px |
bold(README) |
8px | 1px | 25.6px | -0.16px |
zzz-garbage(対照) |
8px | 1px | 25.6px | -0.16px |
READMEに載っている3つの名前は、計測した4トークンすべてででたらめな文字列を指定した場合と1つも違わない値を返した。仕組みとしては、[data-tune]という属性セレクタが全デルタを0に初期化し、そのうえで[data-tune="soft"]のような個別ブロックが値を上書きする構造になっている。定義の無い名前は初期化だけが効いてベース値に落ちる。属性は付いているのでCSSの解決自体は成功し、コンソールにも何も出ない。
なおdefaultは影のトークン(--td-sh1-y/--td-sh1-blur)にだけ値を持つため、厳密には「未定義の名前=defaultと完全に同一」ではない。ただし上記4トークンの範囲では区別がつかず、tuneとしての形・余白の指定は効いていないという結論は変わらない。
これは奇しくも、Pinion UI自身が警戒している「静かに何も起きない」という失敗の形そのものだ。次節のlinterは、まさにこの種の無反応を検出するために用意されている(ただし現時点のui:lintが見るのはBlade内のクラス語彙であり、data-tuneの値そのものは検査対象ではない)。
tune名はREADMEの機能一覧ではなく
src/resources/css/tune.cssを正とする。grep -oE '\[data-tune="[a-z-]+"\]' vendor/sparrowhawk-labs/pinion-ui/src/resources/css/tune.css | sort -u を実行すれば、導入したバージョンで実際に有効な名前が確定する。「46」「56」「60」——コンポーネント数が合わない理由
同じ食い違いは数字にも出ている。ただしこちらは、どれかが誤りというより数え方の違いが大半だ。
| 数字 | 数え方 | 備考 |
|---|---|---|
| 46 | READMEの見出し「46 components」 | 2026-05-14のコミット以降、更新が止まっている |
| 56 | reference/components/index.mdに並ぶ<x-…>の行数 |
公式リファレンス索引の実数 |
| 57 | reference/components/*.mdの個別ページ数 |
索引ページを除いた数 |
| 60 | src/resources/views/components/**/*.blade.phpの実数 |
accordion-itemなどの子部品、pagination/fullなどの入れ子を含む |
60と56の差は、単独では使わない子部品(<x-accordion-item>・<x-tab>・<x-menu-item>など)と入れ子構造(pagination/full・pagination/simple・section/hero)をどう数えるかによる。PinionUiServiceProvider::getComponentNames()は入れ子をドット記法(section.hero)で展開して登録するので、登録名の総数は60側に近い。
46だけは事情が違う。README の履歴を追うと、この数字は2026年5月14日に40→46まで1コミットずつ増えたあと、そこで止まっている。以降の追加分が反映されていない。テーマ数も同様で、READMEは「37 original themes」と書いているが、lineup.jsonの実エントリはv0.9.0の時点で39、現在のmainでは40ある。
なお、元記事(Laravel News)が挙げている「56コンポーネント・39テーマ」という数字は、公式リファレンス索引とv0.10.4時点のlineup.jsonにそれぞれ一致する。公式サイトのpinion-ui.devも39テーマ・858通りと表示しており、更新が止まっているのはREADMEの機能一覧だけ、というのが実態だ。
Pinion UIのAIエージェント連携——AGENTS.mdとPostToolUseフックの中身
ui:installの--aiオプションは、このライブラリで最も作り込まれている部分の1つだ。READMEでは「LLM-native docs」と一言で紹介されているが、実際には2つの独立した仕掛けが入る。
1つ目は静的なドキュメント連携。UiInstall.phpの実装を読むと、--ai(または--claude)を渡した場合、プロジェクトのAGENTS.mdにPinion UIのコア規約が追記され、CLAUDE.mdには@AGENTS.mdという1行のimportが先頭に挿入される。CLAUDE.mdが存在しなければ新規作成される。コード中のコメントは、この二段構えの理由を「Claude CodeはCLAUDE.mdを読むがAGENTS.mdは読まない。importを介せば内容を二重に持たずに済む」と説明している。オプションを付けずに実行した場合は対話で確認され、既定はYesだ。
テーマ定義のlineup.jsonにも同じ思想が現れている。各テーマにはllmTriggerというフィールドがあり、たとえばブランド既定のpinionには「迷ったらこれ。pinion-uiの顔(クリーン技術文書調・クリームホワイトベース)」という、モデルが選択の手がかりにするための説明文が入っている。テーマはブランド既定1件・美学系(mood)11件・SaaS実用12件・業種特化16件の4カテゴリに整理されている。
2つ目が、編集のたびに走るフックだ。こちらのほうが実装として踏み込んでいる。
lint-blade.php participant L as php artisan ui:lint A->>H: Blade を Edit / Write(PostToolUse) H->>H: .blade.php か判定
artisan と vendor の存在確認 H->>L: 編集された1ファイルだけを検査 alt 違反なし(終了コード 0) L-->>H: クリーン H-->>A: 何も出力せず終了(静か) else 違反あり(終了コード 非0) L-->>H: 違反の一覧 H-->>A: additionalContext として
違反内容を文脈へ差し戻す A->>A: 同じターン内で修正 end
stubs/hooks/lint-blade.phpの実装は素直だ。標準入力からフックのJSONを受け取り、tool_input.file_pathが.blade.phpでなければ即終了する。Laravelアプリであること(artisanの存在)と、Pinion UIが実際に入っていること(vendor/sparrowhawk-labs/pinion-uiの存在)を確認したうえで、そのファイルだけにui:lintを掛ける。
注目したいのは、必ず終了コード0で終わるという設計と、その理由がコメントに明記されている点だ。曰く、Claude CodeがPostToolUseフックの出力をモデルの文脈へ注入するのは「終了コード0かつadditionalContextを含むJSONを出力したとき」に限られ、非0終了で標準出力にテキストを出しても、それは人間に表示されるだけでモデルには渡らない。だから違反はJSONの中に載せて運ぶ、という判断になっている。差し戻されるメッセージには、違反の一覧に加えて「代わりに何を使うべきか」(Pinion UIのコンポーネント、tuneのクラス/トークン、daisyUIのセマンティックカラー)と、参照すべきAGENTS.mdのセクション名まで含まれている。
.claude/settings.jsonへの登録も、実装を見ると壊れにくさを意識した作りになっている。マッチャはEdit|Write、コマンドはtest -f "$CLAUDE_PROJECT_DIR/.claude/hooks/lint-blade.php" && php "…" || trueというシェルガード付きで、スクリプトが無い環境では黙って何もしない。登録済みかどうかは既存エントリのコマンド文字列を走査して判定するため、再実行しても重複しない。.claude/hooksがシンボリックリンク(共有スキャフォールドが管理している状態)ならスクリプトを上書きしない、という分岐まで入っている。
このフック導入は
--skip-hooksで抑止できるが、指定しない場合は--aiで自動的に、あるいは対話の既定Yesで有効になる。UIライブラリのインストールが.claude/settings.jsonとCLAUDE.mdを書き換えることになるので、これらをチームで共有している場合は差分を確認してからコミットしたい。エージェントを使わない構成なら--skip-hooksを付ければよい。ui:lintが防ぐ「静かに消えるクラス」
フックの中身であるui:lintが何を見ているかも押さえておきたい。検出対象は2種類だ。
・除外されたdaisyUIのコンポーネントクラス(.btn/.card/.alert/.badgeなど)——プリセットがdaisyUIをコンポーネント除外付きで読み込むため、書いてもCSSが生成されず無反応になる
・固定色・16進カラー(bg-blue-500/#1d4ed8など)——data-themeの切り替えに追随しないため、テーマを変えてもそこだけ色が変わらない
どちらもエラーにならず、見た目が「なんとなく崩れる」形で出る種類の問題だ。だからこそ機械で弾く価値がある。逆に、素のTailwindクラス、daisyUIのセマンティックカラー(bg-primary・text-base-content)、tuneのクラスとトークン、そしてPinion UIが意図的に残しているdaisyUIの一部(progress・timeline・range)は検出対象外になっている。
php artisan ui:lint # resources/views を検査、違反があれば非0で終了
php artisan ui:lint resources/views/livewire # ディレクトリ指定
php artisan ui:lint --json # CI・フック向けの機械可読出力
意図的な例外は、該当行または直前の行にpinion-lint-ignoreコメントを置けば抑止できる。エージェントを使わない場合でも、--git-hookオプションでagent非依存のgit pre-commitフックとして入れられるので、CIに組み込む価値はある。
類似ツールとの比較と、導入前に確認すべき点
Laravel周辺のUI選択肢と並べると、Pinion UIの位置づけがはっきりする。
| Pinion UI | Laravel UI(公式) | daisyUIを素で使う | shadcn/ui系 | |
|---|---|---|---|---|
| 対象スタック | Blade + Alpine + Tailwind v4 | Blade + Bootstrap/Vue/React | 任意(CSSのみ) | React / Next.js中心 |
| 提供物 | Bladeコンポーネント(数え方により46〜60) | 認証まわりの足場 | CSSクラス | コピーして使うReactコンポーネント |
| テーマ切替 | data-theme(39テーマ×light/dark) |
なし | data-theme(組み込みテーマ) |
CSS変数を自前で管理 |
| 形・余白の切替 | data-tune(11プリセット)+strength 5段 |
なし | なし(自前でCSS) | 自前でCSS |
| クラス語彙の検査 | ui:lint+PostToolUseフック |
なし | なし | なし |
| ライセンス | MIT | MIT | MIT | MIT |
daisyUIを素で使う構成と比べたときの実質的な差は、「形と余白の軸が最初から用意されているか」と「使ってはいけないクラスを機械で弾けるか」の2点に集約される。色の切り替えだけならdaisyUIのdata-themeで足りる。tuneに相当する仕組みを自前で組むと、CSS変数の設計とトークンの解決式を自分で持つことになるので、そこを既製で欲しいかどうかが分岐点になる。
なお、Reactを使うプロジェクトでshadcn/uiのテーマを視覚的に調整したい場合や、レイアウト単位のテンプレート集が欲しい場合は、当サイトでも別途取り上げている。あわせてSquare UI:shadcn/uiベースのオープンソースレイアウトUIコレクションやshadcn studio - テーマ生成機能付きshadcn/uiコンポーネント集の活用法も参照してほしい。
導入前に確認しておきたいこと
技術的な設計は作り込まれている一方で、プロジェクトとしての規模はまだ小さい。ここは正直に数字を出しておく。
・GitHubスター24・フォーク2・ウォッチャー0(2026年8月2日時点)
・Packagistの累計ダウンロード93件、直近30日で86件
・初回コミットは2026年5月12日。v0.4.5からv0.10.4まで21リリースと更新は活発
・バージョンは0.x系。実際にv0.4.4でtuneプリセット名の一括改名という破壊的変更が起きている
・必須依存のsparrowhawk-labs/pinion-iconsはスター0(同じ作者の姉妹パッケージ)
つまり「設計は面白く、更新も速いが、他社での運用実績はこれから」という段階にある。0.x系である以上、マイナーバージョンの更新で名前や既定値が変わりうる前提で入れるのが妥当だ。実際に本記事で見たとおり、改名は起きているうえドキュメント側が追随しないこともある。
その意味で、バージョンを固定し、ui:lintをCIに入れておく運用が現実的だろう。前者は破壊的変更の流入を止め、後者は「静かに効かないクラス」を検出してくれる。評価目的で触るなら、まずはdata-tuneを1つずつ切り替えて手元のUIがどう動くかを見るのが早い。この記事で示した実測値は、その際の期待値として使えるはずだ。
参照ソース
・sparrowhawk-labs/pinion-ui — 公式リポジトリ。本記事の実測はv0.10.4(およびmain)のソースに基づく
・sparrowhawk-labs/pinion-ui — CHANGELOG.md — v0.4.4におけるtuneプリセット名の改名記録
・Pinion UI 公式サイト(pinion-ui.dev) — コンポーネントのプレイグラウンドとテーマ/tuneエクスプローラ
・Packagist: sparrowhawk-labs/pinion-ui — リリース履歴とダウンロード実績
・Pinion UI: Restyle an Entire Laravel App by Changing Two HTML Attributes — Laravel News — 本記事のきっかけとなった紹介記事