はじめに
スマートフォンでアプリを操作していると、画面下からニュッと出てくるメニューやアクションシートを目にすることが多いのではないでしょうか。あの「下から生える」UIをReactでゼロから実装しようとすると、ドラッグでの開閉、慣性を考慮したアニメーション、背景のスケーリング演出など、思った以上に作り込みが必要になります。
Vaulは、そんなモバイル向けドロワーUIを驚くほど簡単に実装できるReactライブラリです。今回はVaulの特徴と使い方を、実際のコード例とともに紹介します。
とはいえ、読むより触った方が早いと思います。ボタンを押すと画面下からドロワーが生えてくる、実際に動くVaulのサンプルを用意したので、先に挙動を見たい方はこちらからどうぞ。
Vaulとは
VaulはEmil Kowalski氏が開発した、スタイルを持たない(unstyled)Reactのドロワーコンポーネントです。内部ではRadix UIのDialogをベースにしており、アクセシビリティを担保しながらタブレットやモバイル端末向けのDialog代替として利用できるよう設計されています。shadcn/uiのコンポーネントとしても採用されており、多くのプロダクトで実績のあるライブラリです。
主な特徴
- スタイルフリー - CSSは一切バンドルされておらず、Tailwind CSSなど任意のスタイリング手法と組み合わせて自由にデザインできる
- ネイティブに近い操作感 - ドラッグでの開閉やスワイプでの閉じる動作が標準で実装されており、慣性の効いた自然なアニメーションが得られる
- Snap Pointsに対応 - ドロワーを途中の高さで止める「スナップポイント」を指定でき、地図アプリのようなUIも実現できる
- Nested Drawer対応 - ドロワーの中からさらに別のドロワーを開く、多段階のUIフローを構築できる
- 方向指定が可能 -
directionプロパティで下・上・左・右のいずれからドロワーを出現させるか指定できる
インストール
npmでインストールします。
npm install vaul
yarnやpnpmを使う場合は以下の通りです。
yarn add vaul
pnpm add vaul
Vaulのサンプルを動かす
下のプレイグラウンドは、VaulのDrawer.Root・Drawer.Trigger・Drawer.Portal・Drawer.Contentを組み合わせた最小構成です。「メニューを開く」ボタンを押すと画面下からドロワーがスライドインし、オーバーレイをクリックするかドロワーを下方向にドラッグすると閉じます。開閉のためのuseStateは一切書いていない点に注目してください。
ドロワーの骨格は次のようになっています。Drawer.TriggerにasChildを付けると、子要素(ここでは<button>)がそのままトリガーとして扱われます。
import { Drawer } from 'vaul';
function MyDrawer() {
return (
<Drawer.Root>
<Drawer.Trigger asChild>
<button>メニューを開く</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="overlay" />
<Drawer.Content className="content">
<Drawer.Title>設定メニュー</Drawer.Title>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
}
実際にドラッグやクリックで開閉できるサンプルが以下です。Drawer.Content内のテキストやshouldScaleBackgroundの値を書き換えて、挙動の違いを確かめてみてください。
ドラッグでの開閉・慣性の効いたアニメーション・オーバーレイクリックでのクローズが、状態管理コードを1行も書かずに実現できているのが分かります。shouldScaleBackgroundをfalseにすると、ドロワーを開いたときの背景の縮小演出がオフになります。
基本的な使い方
VaulはDrawer.Rootを起点に、Trigger・Portal・Content・Overlayなどのサブコンポーネントを組み合わせて使います。
import { Drawer } from 'vaul';
function MyDrawer() {
return (
<Drawer.Root>
<Drawer.Trigger asChild>
<button className="rounded bg-black px-4 py-2 text-white">
メニューを開く
</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed bottom-0 left-0 right-0 flex flex-col rounded-t-2xl bg-white p-4">
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-gray-300" />
<Drawer.Title className="text-lg font-semibold">
設定メニュー
</Drawer.Title>
<p className="mt-2 text-sm text-gray-500">
ここに任意のコンテンツを配置できます。
</p>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
}
Drawer.Contentにはドラッグで閉じる操作が自動で組み込まれるため、開閉ロジックを自前で書く必要はありません。Drawer.Overlayをクリックした場合も、デフォルトでドロワーが閉じます。
実践的なユースケース
スナップポイントでUIの高さを制御する
ECサイトの商品詳細やフィルターメニューのように、「少しだけ見せる」「全画面に広げる」を切り替えたい場合はsnapPointsが便利です。
import { useState } from 'react';
import { Drawer } from 'vaul';
const snapPoints = ['148px', '355px', 1];
function ProductFilterDrawer() {
const [snap, setSnap] = useState(snapPoints[0]);
return (
<Drawer.Root
snapPoints={snapPoints}
activeSnapPoint={snap}
setActiveSnapPoint={setSnap}
>
<Drawer.Trigger asChild>
<button className="rounded bg-black px-4 py-2 text-white">
絞り込み
</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed bottom-0 left-0 right-0 rounded-t-2xl bg-white p-4">
<Drawer.Title className="text-lg font-semibold">
絞り込み条件
</Drawer.Title>
<p className="mt-2 text-sm text-gray-500">
ドラッグして表示エリアを調整できます。
</p>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
}
snapPointsにはピクセル値の他、1のように割合(画面全体)も指定できます。ユーザーがドラッグ中の位置に応じて、指定したスナップポイントへ自動的に吸着します。
実際に3段階のスナップポイントを持つドロワーを動かせるサンプルが以下です。ボタンでドロワーを開いたあと、ドロワー上部のハンドルをドラッグして「少しだけ表示」「半分表示」「全画面」の間を移動させてみてください。現在のactiveSnapPointの値もリアルタイムで表示されます。
snapPoints配列の値を['100px', '400px', 1]のように変更すると、途中で止まる高さそのものが変わります。activeSnapPointとsetActiveSnapPointを状態として持っているため、外部のボタンから任意のスナップポイントへプログラム的に移動させることも可能です。
ネストしたドロワーでカート→決済フローを作る
カートの中身を確認するドロワーから、そのまま決済確認のドロワーへ進むといった多段階フローにはDrawer.NestedRootが使えます。
import { Drawer } from 'vaul';
function CartDrawer() {
return (
<Drawer.Root>
<Drawer.Trigger asChild>
<button className="rounded bg-black px-4 py-2 text-white">
カートを見る
</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed bottom-0 left-0 right-0 rounded-t-2xl bg-white p-4">
<Drawer.Title className="text-lg font-semibold">カート</Drawer.Title>
<Drawer.NestedRoot>
<Drawer.Trigger asChild>
<button className="mt-4 rounded bg-black px-4 py-2 text-white">
決済に進む
</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed bottom-0 left-0 right-0 rounded-t-2xl bg-white p-4">
<Drawer.Title className="text-lg font-semibold">
決済確認
</Drawer.Title>
</Drawer.Content>
</Drawer.Portal>
</Drawer.NestedRoot>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
}
Drawer.NestedRootを使うと、外側のドロワーが自動的に少し縮小し、内側のドロワーが手前に重なるアニメーションが得られます。ユーザーは「今どの階層にいるか」を視覚的に把握しやすくなります。
実際にカート→決済の2段階フローを動かせるサンプルが以下です。「カートを見る」で外側のドロワーを開き、その中の「決済に進む」ボタンからDrawer.NestedRootで内側のドロワーを開いてみてください。外側のドロワーが縮小して奥に下がる演出が確認できます。
内側のドロワーを開いたまま外側の背景をよく見ると、外側のDrawer.Contentだけがわずかに縮小しているのが分かります。Drawer.NestedRootの中に、さらにDrawer.NestedRootを重ねることも可能で、フォームの入力確認→最終確認のような3段階以上のフローも同じ書き方で拡張できます。
まとめ
Vaulは、モバイルでよく見かける「下から生えるドロワー」のUX(User Experience:ユーザー体験)を、最小限の実装で再現できるReactライブラリです。スタイルを持たない設計のため既存のデザインシステムに馴染みやすく、Snap PointsやNested Drawerといった実務で使える機能も揃っています。
まずはシンプルなDrawer.Root構成から試してみて、慣れてきたらスナップポイントやネスト構造を取り入れ、アプリのUXを一段引き上げてみてください。
