本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

使い方ツアーで、暗い幕に開いた穴が部品から部品へ形を変えて移り、吹き出しが付いていく

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

画面に暗い幕をかけ、説明する部品の形に穴を開けて、その横の吹き出しで使い方を説明します。「次へ」を押すと、穴が位置・大きさ・角の丸みを変えながら次の部品へ移り、吹き出しも少し遅れて付いていきます。最後の「完了」で、穴が広がりながら幕が晴れます。

幕と穴は 1 つの要素で作ります。穴の周りに、画面を覆うほど大きく広げた影(box-shadow)を付けると、それがそのまま幕になります。幕を形に切り抜く計算が要らず、穴の位置・幅・高さと border-radius を補間するだけで、丸帯のボタンから四角い一覧まで、形ごと移れます。内側には 2px の白い輪を足して、部品を縁取ります。

穴は 0.6 秒の power3.inOut で移り、吹き出しは同じ曲線で 0.05 秒遅れて動きます。吹き出しの文は 0.15 秒で消し、移り始めから 0.3 秒たって消えきってから次の文に替え、6px 下から 0.35 秒かけて出します。重ねてフェードすると、途中で 2 つの文が重なって読めません。始まりは、穴を部品より 48px 大きい形から 0.6 秒で絞り込み、どこを見ればよいかを目で追わせます。

吹き出しは、部品の下に入れば下、入らなければ右に置き、画面の端から 16px 内側に収めます。幕の要素で画面全体を覆って幕の下の部品を押せないようにし、吹き出しは dialog の役割にして、Tab が吹き出しのボタンだけを回るようにします。Esc で閉じたら、ツアーを開いたボタンへ焦点を戻します。文を入れ替える所は aria-live の領域にして、次の手順が読み上げにも届くようにします。

幕の濃さは 0.64 です。明るいサイトでは 0.5 ほどまで薄めても、穴ははっきり見えます。手順は、部品に番号と題・文の属性を付けるだけで増やせます。初めての人にだけ自動で始めるなら、見終えたことをブラウザに保存しておきます。アプリの初回の案内、管理画面の新しい機能の紹介、入力の多いフォームの手引きに向いています。

コード

HTML
<!-- 説明する部品に data-tour(手順の番号)と、吹き出しの題・文を付ける -->
<header class="app-bar">
  <input class="app-bar__search" type="search" placeholder="ファイルや人を検索" aria-label="検索"
    data-tour="1" data-tour-title="検索" data-tour-text="ファイルや人、プロジェクトをここからまとめて探せます。">
  <button class="app-bar__help" type="button" data-tour-start>使い方を見る</button>
  <button class="app-bar__create" type="button"
    data-tour="2" data-tour-title="新しく作成" data-tour-text="新しいプロジェクトや文書は、このボタンから作ります。">新規作成</button>
</header>
<nav class="app-nav" aria-label="メイン"
  data-tour="3" data-tour-title="画面の切り替え" data-tour-text="よく使う画面へは、左の一覧からいつでも移れます。">
  <a href="/dashboard/" aria-current="page">ホーム</a>
  <a href="/projects/">プロジェクト</a>
  <a href="/messages/">メッセージ</a>
</nav>

<!-- ツアー。穴の周りに広げた影が幕になる。吹き出しは、手順ごとに中身を入れ替える -->
<div class="tour" hidden>
  <div class="tour__hole"></div>
  <div class="tour__bubble" role="dialog" aria-modal="true" aria-labelledby="tour-title">
    <div class="tour__content">
      <div aria-live="polite">
        <h2 class="tour__title" id="tour-title"></h2>
        <p class="tour__text"></p>
        <span class="tour__step"></span>
      </div>
      <div class="tour__foot">
        <span class="tour__dots" aria-hidden="true"></span>
        <button class="tour__back" type="button">戻る</button>
        <button class="tour__next" type="button">次へ</button>
      </div>
    </div>
  </div>
</div>

CSS
.tour {
  --dim: 0; /* 幕の濃さ(0〜1)。スクリプトが動かす */
  --ink: #262626;
  --surface: #ffffff;
  --mute: #6b6b6b;
  --line: rgba(38, 38, 38, 0.12);
  position: fixed;
  inset: 0; /* 画面全体を覆い、幕の下の部品を押せないようにする */
  z-index: 1000;
  color: var(--ink);
}

.tour[hidden] { display: none; }

/* 穴。周りに広げた影が幕になり、内側の 2px の白い輪が部品を縁取る */
.tour__hole {
  position: absolute;
  top: 0;
  left: 0;
  box-shadow:
    0 0 0 2px rgb(255 255 255 / var(--dim)),
    0 0 0 200vmax rgb(17 17 17 / calc(0.64 * var(--dim)));
  pointer-events: none;
}

.tour__bubble {
  position: absolute;
  top: 0;
  left: 0;
  width: min(336px, calc(100vw - 32px));
  box-sizing: border-box;
  padding: 24px;
  border-radius: 4px;
  background-color: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line); /* 影は付けず、1px の罫線で縁を取る */
}

.tour__title { margin: 0; font-size: 20px; line-height: 1.3; letter-spacing: -0.01em; }
.tour__text { margin: 8px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }

/* 読み上げだけに届ける「手順 1 / 3」 */
.tour__step { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.tour__foot { display: flex; align-items: center; gap: 4px; margin-top: 24px; }
.tour__dots { display: flex; flex: 1; gap: 6px; }

.tour__dots i {
  width: 8px;
  height: 8px;
  border-radius: 999px;
  background-color: rgba(38, 38, 38, 0.2);
  transition: width 0.4s, background-color 0.4s;
}

.tour__dots .is-current { width: 20px; background-color: var(--ink); }

.tour__back,
.tour__next {
  height: 40px;
  padding: 0 16px;
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--mute);
  font: inherit;
  font-size: 14px; /* font: inherit の後に書く(先だと打ち消される) */
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.tour__next { padding: 0 20px; background-color: var(--ink); color: var(--surface); }
.tour__back:focus-visible,
.tour__next:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .tour__dots i { transition: none; }
}

/* 例の画面(ツアーで説明する部品)。案件では、自分の画面の部品に data-tour を付ける */
.app-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 16px; }
.app-bar__search { width: min(320px, 100%); height: 40px; padding: 0 12px; border: 1px solid rgba(38, 38, 38, 0.12); border-radius: 4px; font: inherit; }
.app-bar__help { height: 40px; padding: 0 16px; border: 1px solid #262626; border-radius: 999px; background: none; font: inherit; cursor: pointer; }
.app-bar__create { height: 40px; padding: 0 20px; border: 0; border-radius: 999px; background-color: #262626; color: #ffffff; font: inherit; font-weight: 600; }
.app-nav { display: flex; flex-direction: column; gap: 4px; width: 200px; padding: 8px 16px; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const PAD = 8; // 部品と穴の縁のすき間
const GAP = 16; // 穴と吹き出しのすき間(画面の端からも、これだけ離す)
const GROW = 48; // 始まりと終わりに、穴を部品より大きくする量

const tour = document.querySelector(".tour");
const parts = ["hole", "bubble", "content", "title", "text", "step", "dots", "back", "next"];
const [hole, bubble, content, title, text, step, dots, back, next] = parts.map((name) => tour.querySelector(`.tour__${name}`));
const steps = [...document.querySelectorAll("[data-tour]")].sort((a, b) => a.dataset.tour - b.dataset.tour);
dots.innerHTML = steps.map(() => "<i></i>").join("");
let index = -1; // 今の手順(-1 は閉じている)
let opener = null; // 閉じたら焦点を戻す所
let motion = null; // 動いているタイムライン。新しい動きを始めるときに止める
const run = (timeline) => { motion?.kill(); motion = timeline.timeScale(SPEED); };

function fill(i) {
  title.textContent = steps[i].dataset.tourTitle;
  text.textContent = steps[i].dataset.tourText;
  step.textContent = `手順 ${i + 1} / ${steps.length}`;
  [...dots.children].forEach((dot, k) => dot.classList.toggle("is-current", k === i));
  back.hidden = i === 0;
  next.textContent = i === steps.length - 1 ? "完了" : "次へ";
  if (!bubble.contains(document.activeElement) || back.hidden) next.focus({ preventScroll: true });
}

// 部品の位置から、穴の形と吹き出しの位置を決める。吹き出しは下に入れば下、入らなければ右に置き、画面の中に収める
function layout(i) {
  const r = steps[i].getBoundingClientRect();
  const box = { left: r.left - PAD, top: r.top - PAD, width: r.width + PAD * 2, height: r.height + PAD * 2 };
  box.borderRadius = Math.min((parseFloat(getComputedStyle(steps[i]).borderTopLeftRadius) || 0) + PAD, box.height / 2);
  const below = box.top + box.height + GAP;
  const fits = below + bubble.offsetHeight <= innerHeight - GAP;
  return {
    box,
    x: gsap.utils.clamp(GAP, innerWidth - bubble.offsetWidth - GAP, fits ? box.left : box.left + box.width + GAP),
    y: gsap.utils.clamp(GAP, innerHeight - bubble.offsetHeight - GAP, fits ? below : box.top),
  };
}

function start(from) {
  if (index >= 0) return;
  [opener, index, tour.hidden] = [from, 0, false];
  fill(0);
  const { box, x, y } = layout(0);
  const wide = { left: box.left - GROW, top: box.top - GROW, width: box.width + GROW * 2, height: box.height + GROW * 2 };
  run(gsap.timeline()
    .fromTo(tour, { "--dim": 0 }, { "--dim": 1, duration: 0.4, ease: "none" }, 0) // 幕が暗くなる
    .fromTo(hole, { ...wide, borderRadius: box.borderRadius + GROW / 2 }, { ...box, duration: 0.6, ease: "power3.out" }, 0) // 大きめから絞る
    .fromTo(bubble, { x, y: y + 8, opacity: 0 }, { y, opacity: 1, duration: 0.45, ease: "power2.out" }, 0.25));
}

function go(i) {
  if (index < 0 || i === index) return;
  index = i;
  const { box, x, y } = layout(i);
  run(gsap.timeline()
    .to(hole, { ...box, duration: 0.6, ease: "power3.inOut" }, 0) // 位置・大きさ・角の丸みを変えながら移る
    .to(bubble, { x, y, duration: 0.6, ease: "power3.inOut" }, 0.05) // 吹き出しは少し遅れて付いてくる
    .to(content, { opacity: 0, duration: 0.15 }, 0)
    .call(fill, [i], 0.3) // 前の文が消えきってから替える
    .fromTo(content, { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.35, ease: "power2.out" }, 0.3));
}

function end() {
  if (index < 0) return;
  index = -1;
  run(gsap.timeline({ onComplete: () => ((tour.hidden = true), opener?.focus()) })
    .to(bubble, { opacity: 0, duration: 0.2 }, 0)
    .to(hole, { left: `-=${GROW}`, top: `-=${GROW}`, width: `+=${GROW * 2}`, height: `+=${GROW * 2}`, duration: 0.4, ease: "power2.in" }, 0)
    .to(tour, { "--dim": 0, duration: 0.4, ease: "none" }, 0));
}

next.addEventListener("click", () => (index < steps.length - 1 ? go(index + 1) : end()));
back.addEventListener("click", () => go(index - 1));
document.querySelectorAll("[data-tour-start]").forEach((button) => button.addEventListener("click", () => start(button)));

// Tab は吹き出しの中のボタンだけを回る。幕を押しても焦点を外へ出さない。Esc は焦点がどこにあっても閉じる
bubble.addEventListener("keydown", (event) => {
  if (event.key !== "Tab") return;
  event.preventDefault();
  const buttons = [back, next].filter((button) => !button.hidden);
  buttons.at((buttons.indexOf(document.activeElement) + (event.shiftKey ? -1 : 1)) % buttons.length).focus();
});
tour.addEventListener("pointerdown", (event) => event.target === tour && event.preventDefault());
document.addEventListener("keydown", (event) => event.key === "Escape" && end());

// 画面の大きさやスクロールが変わったら、今の手順の位置に置き直す
function relayout() {
  if (index < 0 || motion?.isActive()) return;
  const { box, x, y } = layout(index);
  gsap.set(hole, box);
  gsap.set(bubble, { x, y });
}
["resize", "scroll"].forEach((type) => addEventListener(type, relayout, { passive: true }));

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。