🗺

WebでGoogleマップのようなハーフモーダルを実装する

2024/08/29に公開

Vaul とは

「ハーフモーダル」という UI パターンをご存知でしょうか?

モーダルウィンドウの一種で、画面の下半分を覆うように表示される UI パターンです。iOS 15 で導入されてから、スマートフォンではよく見かけるようになりました。

https://qiita.com/akatsuki174/items/152ba0848dbd375dae4e

黒色の背景にベゼルがついたiOSの画面が横に5つ並んでおり、左からメモ、マップ、Safari、Reader、メールが表示されている。いずれの画面もテキスト選択やメールのスワイプジェスチャなどのアクションが行われている最中であり、画面下半分にコンテンツにかぶさる形でアクションメニューが表示されている。
Customize and resize sheets in UIKitより

ハーフドロワーや半モーダルといった呼び方もあるようです。

Vaul はこのようなハーフモーダルを React で実装するためのライブラリで、スタイルが提供されていないヘッドレスではありますが様々なカスタマイズができるようになっています。

https://vaul.emilkowal.ski/

Radix UI の Dialog コンポーネントをベースに作られているため、Radix UI で利用可能な APIをそのまま利用することができます。

基本的な実装

Vaul を使って、基本的なハーフモーダルを実装してみます。

まずは npm から Vaul をインストールします。

npm install vaul

Vaul のコンポーネントを使ってハーフモーダルを実装します。

HalfModal.tsx
"use client";

import { useState } from "react";

import { Drawer } from "vaul";

const HalfModal: React.FC = () => {
  return (
    <Drawer.Root shouldScaleBackground>
      <Drawer.Trigger asChild>
        <button className={styles.drawer_trigger}>Open Drawer</button>
      </Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Overlay className={styles.drawer_overlay} />
        <Drawer.Content className={styles.drawer_content}>
          <div className={styles.drawer_inner}>
            <div className={styles.handle} />
            <div className="max-w-md mx-auto">
              <Drawer.Title className={styles.title}>
                This is half drawer
              </Drawer.Title>
            </div>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  );
};

export default HalfModal;
HalfModal.module.css
.drawer_trigger {
  background-color: #03a9f4;
  color: white;
  border-radius: 0.5rem;
  font-size: 1.5rem;
  padding: 0.5rem 1rem;
  position: fixed;
  bottom: 1.5rem;
  right: 1rem;
  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.5);
  z-index: 1001;
  transition: all 0.3s;

  &:hover {
    background-color: #0288d1;
  }
}

.drawer_overlay {
  position: fixed;
  inset: 0;
  background-color: rgba(0, 0, 0, 0.4);
  z-index: 1001;
}

.drawer_content {
  background-color: white;
  border-radius: 1rem 1rem 0 0;
  height: 30%;
  margin-top: 6rem;
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 1002;
}

.drawer_inner {
  padding: 1rem;
  background-color: white;
  border-radius: 1rem 1rem 0 0;
  flex: 1;
}

.handle {
  margin-left: auto;
  margin-right: auto;
  width: 3rem;
  height: 0.375rem;
  flex-shrink: 0;
  border-radius: 9999px;
  background-color: #d4d4d8;
  margin-bottom: 2rem;
  cursor: pointer;
}

ページコンポーネントに組み込み、ローカルサーバーで確認してみます。

npm run dev

Vaulを動かしている様子。背景に東京駅を中心とした地図があり、右下にモーダルを開くためのボタンがある。動画ではモーダル最上部のハンドルをドラッグすることによって閉じる方法と、モーダル外の灰色にオーバレイされたエリアをクリックすることによって閉じる方法の2種類が例示されている。
Vaul によるハーフモーダルの実装例 1

最低限のハーフモーダルを実装することが出来ました。

モーダルとモードレス

さて、ここで 現在の UI と Google マップで採用されている UI を比較してみます。

スマートフォンのGoogleマップアプリで東京駅を表示している様子。画面下半分に東京駅に乗り入れている路線とそれぞれのダイヤが表示されているモーダルがあり、この部分は高さが可変になっている。また、画面上半分に表示されている地図の部分を操作してもこのモーダルは隠れない。
Google マップで東京駅を表示

モーダルウィンドウは、ユーザーがそのウィンドウを閉じるまで他の操作を行えないようにする UI パターンです。モーダルウィンドウが表示されている間は、モーダルウィンドウ以外の UI は操作できないようになります。先ほど実装したのはこちらの UI パターンです。

一方でモードレスウィンドウは、ユーザーがそのウィンドウを閉じるまで他の操作を行えるようにする UI パターンです。モードレスウィンドウが表示されている間も、モードレスウィンドウ以外の UI は操作できるようになります。今回実装したいモバイル版 Google マップで採用されている UI は、モードレスウィンドウの一種です。

このため、Google マップの UI は厳密にはハーフモーダルとは異なるのですが、ネット上の記事を見ると Google マップの UI をハーフモーダルに括っているものも多いようなので、ここでも便宜的にハーフモーダルと呼ぶことにします。

Google マップのような UI を実装する

実際に Vaul を使ってハーフモーダルを実装してみます。

グローバルに適用している CSS ファイルで、以下のようなスタイルを追加します。

global.css
body {
  pointer-events: auto !important;
}

多少 hack 的な方法であることは否めませんが、Vaul がbodyに対して指定しているpointer-events: none;を上書きすることで、このような UI を実装することができます。

実は Vaul にはbodyへのスタイル指定を無効化するためのnoBodyStylesという prop が用意されているのですが、なぜかpointer-eventsに対する指定だけは無効化されないようです。これが仕様通りの挙動なのかは分かりませんが、いまいち用途が分からないので普通にバグではないかと疑っています。軽く Issue もチェックしたんですが、同じように悩んでいそうな人がいた。

次に、先ほど書いた 2 つのファイルを以下のように修正します。

HalfModal.tsx
"use client";

import { useState } from "react";

import { Drawer } from "vaul";

import styles from "./HalfModal.module.css";

const HalfModal: React.FC = () => {
  const [snap, setSnap] = useState<number | string | null>("100px");

  return (
    <Drawer.Root
      activeSnapPoint={snap}
      disablePreventScroll={false}
      open={true}
      setActiveSnapPoint={setSnap}
      snapPoints={["115px", "240px", "480px", 0.9]}
    >
      <Drawer.Trigger />
      <Drawer.Portal>
        <Drawer.Content className={styles.content}>
          <div className={styles.inner_content}>
            <div className={styles.handle} />
            <div>Main Content</div>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  );
};

export default HalfModal;

Vaul では snapPoints というプロパティを使ってモーダルのスナップポイントを指定することができます。この配列にはピクセル値だけでなく 0 から 1 までの間でパーセンテージによる指定をすることもできます。

HalfModal.module.css
.content {
  background-color: #f4f4f5;
  display: flex;
  flex-direction: column;
  border-top-left-radius: 1rem;
  border-top-right-radius: 1rem;
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 1001;
  height: 100%;
  box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
}

.inner_content {
  padding: 1rem;
  background-color: white;
  border-top-left-radius: 10px;
  border-top-right-radius: 10px;
  flex-grow: 1;
}

.handle {
  margin: 0 auto;
  width: 3rem;
  height: 0.375rem;
  border-radius: 9999px;
  background-color: #d4d4d8;
  margin-bottom: 1rem;
  cursor: grab;
}

これで以下のようなハーフモーダルを実装できるはずです。

VaulをGoogleマップのUIに近づけて作ったものを動かしている様子。Vaulで実装したウィンドウ外を操作してもVaulが閉じることなく操作できている。
Vaul によるハーフモーダルの実装例 2

おわりに

Vaul では他にも様々な APIが提供されているので、かなり柔軟にカスタマイズすることができます。ヘッドレスなライブラリなのでスタイルは自分で書く必要がありますが、公式が作例を用意してくれているので、それをそのまま使ってしまうのもアリだと思います。

ハーフモーダルやこれに準じた UI を実装する際はぜひ試してみてください。

最後に、今回 Vaul を採用したプロジェクトのリポジトリと、その他参考にした記事をリンクしておきます。

https://github.com/newt239/michikusa_front

https://zenn.dev/hayato94087/articles/ea7c4eb4f4c307

Discussion