Reactで、画面の部品を順に案内する使い方ガイドを作る方法

​​React​dialog,localStorage,Playwright,scrollIntoView,TypeScript,useLayoutEffect

画面の部品を順に案内するガイドは、案内したい部品に data-tour の名前を付け、その名前とカードの文言を並べた配列を1つずつ進める形で作れます。今のステップの部品の位置を getBoundingClientRect() で測り、部品のまわりだけを明るく残す枠と、説明のカードをその位置に置きます。ガイドの間はモーダルの dialog に入れておけば、後ろの画面を誤って操作することもありません。

ここでは、メモのアプリを模した画面で、検索欄、「+ 新規」ボタン、メモの一覧、ページの下にある「設定」ボタンの4つを順に案内しました。最初の訪問では自動で開き、最後まで見るかスキップしたら、次からは「使い方を見る」ボタンを押したときだけ出します。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、画面の大きさを 390×640px にして Playwright で操作しました。StrictMode を付けた開発ビルドでも同じ操作をしたところ、出力は本番ビルドとまったく同じでした。Firefox と Safari、スクリーンリーダーでの読み上げは試していません。

「使い方を見る」を押すとガイドが開き、検索欄だけが明るく残る。「次へ」を押すたびに「+ 新規」、メモの一覧へと明るい枠とカードが移り、4つ目ではページの下の「設定」ボタンまでスクロールして、カードがボタンの上に出る。「完了」でガイドが閉じる
5回のクリックで、ガイドが開き、案内する部品が移っていく様子。ステップの番号が変わった瞬間だけ、番号をピンクにしている(カーソルと操作の表示は GIF 用に重ねたもの)

案内する部品に名前を付け、ステップを配列で並べる

案内したい部品には、data-tour 属性で名前を付けます。ステップの配列には、その名前と、カードに出す見出しと説明を並べます。部品を ref で受け渡すより、離れた場所にある部品も名前だけで指せるので、ステップを足したり並べ替えたりしやすくなります。

tsx
const STEPS: TourStep[] = [
  { target: 'search', title: 'メモを探す', body: 'ここに言葉を入れると、メモを絞り込めます。' },
  { target: 'new', title: 'メモを作る', body: '新しいメモは、このボタンから作ります。' },
  { target: 'list', title: 'メモの一覧', body: '作ったメモは、新しい順にここへ並びます。' },
  { target: 'settings', title: '設定', body: '文字の大きさなどは、ページの下の設定で変えられます。' },
];

STEPS はコンポーネントの外に置いています。あとで見るとおり、ガイドの側では今のステップのオブジェクトを useCallback と effect の依存に入れています。依存の値は Object.is で前回と比べられるので、配列をコンポーネントの中で作ると、App が描画し直されるたびに別のオブジェクトになります。そうすると、ステップが変わっていなくても、部品へのスクロールをやり直すことになるはずです(この場合は試していません)。

画面の側は、部品に data-tour を付けて、ガイドを出すかどうかを state で持つだけです。

tsx

return (
    <main>
      <header className="bar">
        <input data-tour="search" type="search" placeholder="メモを検索" aria-label="メモを検索" />
        <button data-tour="new" type="button" onClick={() => setCount(count + 1)}>
          + 新規
        </button>
      </header>
      <p data-testid="count">作ったメモ:{count}</p>
      <ul data-tour="list" className="notes">
        <li>買い物のメモ</li>
        <li>読みたい本</li>
        <li>旅行の持ち物</li>
      </ul>
      <button type="button" className="help" onClick={() => setTouring(true)}>
        使い方を見る
      </button>
      <div className="spacer" />
      <button data-tour="settings" type="button">
        設定
      </button>
      {touring && <Tour steps={STEPS} onClose={handleClose} />}
    </main>
  );

spacer は高さ 900px の空きで、「設定」ボタンを最初の画面に入らない位置へ押し下げるために入れています。

ガイドをモーダルの dialog に入れ、部品のまわりだけを明るく残す

ガイドの本体は、画面いっぱいに広げた dialog です。dialog の中に、明るく残す枠(tour-spot)と、説明のカード(tour-card)を置きます。

tsx

return (
    <dialog
      ref={dialogRef}
      className={spot ? 'tour' : 'tour no-target'}
      aria-labelledby="tour-title"
      aria-describedby="tour-body"
      onClose={() => onClose(finished.current)}
    >
      {spot && (
        <div
          className="tour-spot"
          style={{ left: spot.left, top: spot.top, width: spot.width, height: spot.height }}
        />
      )}
      <div ref={cardRef} className="tour-card" style={{ left: card.left, top: card.top }}>
        <p className="tour-count">
          {index + 1} / {steps.length}
        </p>
        <h2 id="tour-title">{step.title}</h2>
        <p id="tour-body">{step.body}</p>
        <div className="tour-buttons">
          <button type="button" className="tour-skip" onClick={() => close(false)}>
            スキップ
          </button>
          {index > 0 && (
            <button type="button" onClick={() => setIndex(index - 1)}>
              戻る
            </button>
          )}
          <button
            type="button"
            className="tour-next"
            onClick={() => (last ? close(true) : setIndex(index + 1))}
          >
            {last ? '完了' : '次へ'}
          </button>
        </div>
      </div>
    </dialog>
  );

周りを暗くするのは、枠の box-shadow です。4つ目の値の広がり(spread radius)に 9999px を指定すると、影が枠から画面の外まで広がり、枠の中だけが暗くならずに残ります。dialog 自体と、その後ろに出る ::backdrop は透明にしておきます。

css
.tour { position: fixed; inset: 0; width: auto; height: auto; max-width: none; max-height: none;
  margin: 0; padding: 0; border: 0; background: transparent; overflow: visible; }
.tour::backdrop { background: transparent; }
.tour.no-target::backdrop { background: rgb(0 0 0 / 0.5); }
.tour-spot { position: fixed; border-radius: 8px; pointer-events: none;
  box-shadow: 0 0 0 9999px rgb(0 0 0 / 0.5); }

dialog は、showModal() で開きます。モーダルで開くと、dialog の外の要素はすべて inert と同じ扱いになり、クリックもフォーカスも届かなくなります。ガイドの最中に、明るく残した「+ 新規」ボタンを誤って押してしまうことも防げます。

tsx

// 開いたら、後ろを操作できないモーダルとして出す(閉じた dialog の中は測れないので、測るより先に)
  useLayoutEffect(() => {
    const d = dialogRef.current!;
    if (!d.open) d.showModal();
  }, []);

if (!d.open) は、StrictMode の開発ビルドで effect がもう一度実行されたときに、開いている dialog へもう一度 showModal() を呼ばないためのものです。

開いた直後の結果です。dialog はモーダルで開き、明るく残した「+ 新規」ボタンの位置をクリックしても、メモの数は 0 のままでした。Tab キーを4回押しても、フォーカスはカードの中のボタンと body(ページのどの要素にもフォーカスがない状態)の間を巡るだけで、後ろの画面の部品には移りませんでした。

text
dialog: open=true modal=true labelledby=tour-title describedby=tour-body
「+ 新規」の位置をクリック: 作ったメモ:0

showModal() は、フォーカスを移す処理より先に呼ぶ

showModal() は、useEffect ではなく useLayoutEffect で呼んでいます。MDN の dialog のページにあるとおり、showModal() で開くと、ブラウザは中の最初のフォーカスできる要素にフォーカスを移します。そこで、次の節の、カードを測って「次へ」にフォーカスを移す処理(これも useLayoutEffect)より前に開いておきます。

showModal() を呼ぶ effect だけを useEffect に変えた版と比べたところ、開いた直後のフォーカスは「次へ」ではなく「スキップ」になりました。次の出力の / が元の版、/effect/ が変えた版です。1つ目のステップのカードの位置は、どちらも同じでした。

text
/: dialog open=true / フォーカス button「次へ」
  カード left=14 top=73.59375 right=314 bottom=252.359375
/effect/: dialog open=true / フォーカス button「スキップ」
  カード left=14 top=73.59375 right=314 bottom=252.359375

測る処理についても、同じページにあるとおり閉じている dialog は display: none なので、その中のカードの大きさは 0 と測られるはずです。ただ、1つ目のステップはカードを部品の下に置いて左をそろえるだけなので、大きさが 0 でも位置は変わらず、この検証では差が出ませんでした。

部品の位置を測って、枠とカードを置く

今のステップの部品を data-tour の名前で探し、getBoundingClientRect() で位置を測ります。枠は部品より 6px ずつ大きくし、カードは枠の下に 12px あけて置きます。下に入らなければ上に置き、どちらの場合も、カードが画面からはみ出さないように内側へずらします。

tsx

// 部品の位置を測り、光らせる枠とカードの位置を決める
  const measure = useCallback(() => {
    const el = findTarget(step);
    const { width: cw, height: ch } = cardRef.current!.getBoundingClientRect();
    const vw = document.documentElement.clientWidth;
    const vh = window.innerHeight;
    const r = el?.getBoundingClientRect();
    if (!r || (r.width === 0 && r.height === 0)) {
      // 部品が見つからないか、display: none などで表示されていなければ、枠を出さずにカードを画面の真ん中に置く
      setSpot(null);
      setCard({ left: (vw - cw) / 2, top: (vh - ch) / 2 });
      return;
    }
    const s = { left: r.left - PAD, top: r.top - PAD, width: r.width + PAD * 2, height: r.height + PAD * 2 };
    // 下に入れば下、入らなければ上に置く。どちらでも、カードは画面の中に収める
    const below = s.top + s.height + GAP;
    const top = below + ch <= vh - MARGIN ? below : s.top - GAP - ch;
    // 左は部品にそろえ、画面からはみ出す分だけ内側へずらす
    const left = Math.min(Math.max(MARGIN, s.left), vw - MARGIN - cw);
    setSpot(s);
    setCard({ left, top: Math.min(Math.max(MARGIN, top), vh - MARGIN - ch) });
  }, [step]);

PAD、GAP、MARGIN は、それぞれ 6、12、8 の定数です。findTarget は、document.querySelector で [data-tour="search"] のような要素を探すだけの関数です。

「次へ」で進めたときの、部品・枠・カードの位置です。枠は、どのステップでも部品より上下左右に 6px 大きくなりました。2つ目の「+ 新規」ボタンは画面の右端にあるので、カードを枠の左端(282px)にそろえると右にはみ出します。そのため、カードの右端が画面の右端から 8px の位置(382px)になるまで、左にずれました。

text
次へ: 2 / 4 メモを作る
  部品  left=288.40625 top=16 right=370 bottom=55.59375
  枠    left=282.40625 top=10 right=376 bottom=61.59375
  カード left=82 top=73.59375 right=382 bottom=252.359375
次へ: 3 / 4 メモの一覧
  部品  left=20 top=113.1875 right=370 bottom=240.96875
  枠    left=14 top=107.1875 right=376 bottom=246.96875
  カード left=14 top=258.96875 right=314 bottom=437.734375

画面の外にある部品は、画面に入れてから測る

4つ目の「設定」ボタンは、ページの下(上から 1196px)にあり、開いた時点の画面には入っていません。そこで、ステップが変わるたびに、部品を scrollIntoView() で画面に入れてから測ります。block: 'center' を指定すると、部品が画面の上下の真ん中に来るようにスクロールします。

tsx

// 部品を画面の真ん中あたりへスクロールしてから測る
  const bringIntoView = useCallback(() => {
    findTarget(step)?.scrollIntoView({ block: 'center', inline: 'nearest' });
    measure();
  }, [step, measure]);
tsx

// ステップが変わったら、部品を画面に入れて測り、「次へ」にフォーカスを移す
  useLayoutEffect(() => {
    bringIntoView();
    cardRef.current!.querySelector<HTMLElement>('.tour-next')!.focus();
  }, [bringIntoView]);

測る処理を useLayoutEffect に入れているのは、ブラウザが画面を描く前に、枠とカードを新しい位置へ動かすためです。公式リファレンスによると、useLayoutEffect の中の処理と、そこで更新した state は、画面が描かれる前に反映されます。

「設定」のステップに進んだときの結果です。ページは 612px スクロールし、ボタンは画面の真ん中ではなく、下の端に近い位置(bottom=624)に来ました。ページの高さは 1252px なので、高さ 640px の画面では 612px より下へはスクロールできず、ボタンを真ん中まで上げられないためです。カードは下に入らないので、ボタンの上に出ています。最後のステップでは、「次へ」が「完了」に変わりました。

text
ページの高さ(scrollHeight): 1252
scrollY: 612
次へ: 4 / 4 設定
  部品  left=20 top=584.5625 right=82.015625 bottom=624.15625
  枠    left=14 top=578.5625 right=88.015625 bottom=630.15625
  カード left=14 top=387.796875 right=314 bottom=566.5625
ボタン: スキップ / 戻る / 完了

スクロールや画面の大きさの変化についていく

dialog を開いていても、ページのスクロールはできます。スクロールしたら測り直し、枠が部品についていくようにします。画面の大きさが変わったときは、部品が画面の外に出ることがあるので、もう一度画面に入れてから測ります。

tsx

// スクロールしたら測り直す。画面の大きさが変わったら、部品を画面に入れ直す
  useEffect(() => {
    window.addEventListener('scroll', measure, true);
    window.addEventListener('resize', bringIntoView);
    return () => {
      window.removeEventListener('scroll', measure, true);
      window.removeEventListener('resize', bringIntoView);
    };
  }, [measure, bringIntoView]);

「設定」のステップで、マウスのホイールで上へ 200px、さらに 400px スクロールした結果です。枠はボタンと一緒に下へ動き、1回目ですでに画面の外(640px より下)に出て、2回目ではさらに下へ動きました。一方、カードは下の端から 8px の位置(bottom=632)で止まり、画面に残りました。measure の最後で、カードを画面の中に収めているためです。カードが画面に残るので、「完了」や「スキップ」のボタンも画面の中にあります。

text
ホイールで上へ: 4 / 4 設定
  部品  left=20 top=784.5625 right=82.015625 bottom=824.15625
  枠    left=14 top=778.5625 right=88.015625 bottom=830.15625
  カード left=14 top=453.234375 right=314 bottom=632
さらに上へ: 4 / 4 設定
  部品  left=20 top=1184.5625 right=82.015625 bottom=1224.15625
  枠    left=14 top=1178.5625 right=88.015625 bottom=1230.15625
  カード left=14 top=453.234375 right=314 bottom=632

続けて、画面の高さを 400px に縮めました。resize で部品を画面に入れ直したので、ボタンは画面の中(bottom=384)に戻り、カードはボタンの上に出ました。

text
高さを 400px に: 4 / 4 設定
  部品  left=20 top=344.5625 right=82.015625 bottom=384.15625
  枠    left=14 top=338.5625 right=88.015625 bottom=390.15625
  カード left=14 top=147.796875 right=314 bottom=326.5625

閉じたことを覚えて、2回目からは自動で出さない

ガイドを閉じる方法は3つあります。最後のステップの「完了」、どのステップでも押せる「スキップ」、そして Esc キーです。どれも最後は dialog を閉じ、dialog の onClose から親に知らせます。

tsx

function close(done: boolean) {
    finished.current = done;
    dialogRef.current!.close();
  }

Esc キーは、ブラウザが cancel イベントのあとに dialog を閉じます。コンポーネントの close 関数を通らないので finished は false のままになり、スキップと同じ扱いになります。

親の側では、閉じたら localStorage に記録し、次に開いたときは記録を見て、自動では出さないようにします。localStorage は、ブラウザの設定でデータの保存を止めていると例外(SecurityError)を投げるので、読み書きを try で包んでいます。

tsx
const KEY = 'tour-done';

function loadDone() {
  try {
    return localStorage.getItem(KEY) === '1';
  } catch {
    return false;
  }
}

export function App() {
  // 最初の訪問だけ、自動でガイドを出す
  const [touring, setTouring] = useState(() => !loadDone());
  const [count, setCount] = useState(0);

  function handleClose(finished: boolean) {
    setTouring(false);
    try {
      localStorage.setItem(KEY, '1');
    } catch {
      // 保存できなくても、ガイドは閉じる
    }
    document.title = finished ? '完了で閉じた' : 'スキップで閉じた';
  }

最後の document.title は、どの閉じ方だったかを確かめるために、ページのタイトルに出しているだけです。閉じ方によって処理を分けたいときは、この finished の値を使います。

「完了」で閉じてからページを読み込み直した結果です。localStorage に 1 が入り、読み込み直したあとはガイドが自動では開きませんでした。「使い方を見る」を押すと、1つ目のステップから開きます。Esc キーとスキップで閉じたときは、どちらもスキップとして扱われ、フォーカスは「使い方を見る」ボタンに戻りました。検証環境の Chromium では、dialog を閉じたときにブラウザがフォーカスを戻しており、戻す処理は書いていません。

text
--- 完了 ---
dialog: なし / title: 完了で閉じた / localStorage: 1
--- 2回目の訪問 ---
dialog: なし
「使い方を見る」: 1 / 4 メモを探す
Esc: dialog なし / title: スキップで閉じた / フォーカス button「使い方を見る」
スキップ: dialog なし / title: スキップで閉じた / フォーカス button「使い方を見る」

案内する部品が画面にないとき

画面の幅によっては描画しないメニューのように、ステップの部品がページにないこともあります。CSS の display: none で隠した部品は、querySelector では見つかりますが、getBoundingClientRect() の幅と高さが 0 になります。measure では、部品が見つからないときと、幅と高さが 0 のときは枠を出さず、カードを画面の真ん中に置きます。そのときは dialog に no-target のクラスを付け、::backdrop で画面全体を暗くします。

メモの一覧の data-tour を外してから3つ目のステップまで進めた結果と、「+ 新規」ボタンを display: none にしてから2つ目のステップに進めた結果です。どちらも枠は出ず、カードは画面の真ん中に出ました。一覧のときに調べた ::backdrop の色は、半透明の黒でした。

text
一覧の data-tour を外して3つ目へ: 3 / 4 メモの一覧
  枠    なし
  カード left=45 top=230.609375 right=345 bottom=409.375
::backdrop の色: rgba(0, 0, 0, 0.5)
「+ 新規」を display: none にして2つ目へ: 2 / 4 メモを作る
  枠    なし
  カード left=45 top=230.609375 right=345 bottom=409.375
ポイント

案内したい部品に data-tour の名前を付け、STEPS に1行足せば、ステップを増やせます。部品の位置はステップが変わるたびに測り直すので、画面のレイアウトが変わっても、ステップの側を書き直す必要はありません。

​Posted by 86