デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面全体が暗くなり、説明したい部品のところだけが、その形の穴のように明るく残ります。横の吹き出しで使い方を伝え、「次へ」を押すと、穴が位置も大きさも角の丸みも変えながら次の部品へ移り、吹き出しが少し遅れて付いていきます。最後の「完了」では、穴が広がりながらオーバーレイが消えます。
暗いオーバーレイと穴は、ひとつの要素にまとめました。穴にあたる要素に、画面を覆うほど大きく広げた影(box-shadow)を付ければ、その影がそのままオーバーレイになります。部品の形に切り抜く計算は要らず、穴の位置・幅・高さと border-radius を補間するだけで、角の丸いボタンから四角い一覧まで形ごと移れます。穴の縁に沿っては2pxの白い輪を加えて、部品を縁取っています。
穴の移動は0.6秒の power3.inOut です。ツアーの始まりだけは、穴を部品より48px大きい形から絞り込んで、見る場所へ目を導きます。吹き出しの文は、前の文が消えきるのを待ってから差し替えるので、ふたつの文が重なって見える瞬間がありません。オーバーレイの濃さは0.64で、明るいサイトなら0.5ほどまで薄めても穴ははっきり見えます。
オーバーレイの要素が画面全体を覆っているので、ツアーの最中に下の部品を押してしまうことはありません。吹き出しは dialog の役割にして、Tab が吹き出しのボタンだけを巡るようにし、Esc で閉じたらツアーを開いたボタンへフォーカスを戻します。文を入れ替える部分は aria-live の領域なので、次の手順も読み上げで届きます。手順は、部品に番号と題・文の属性を付けるだけで増やせます。
コード
<!-- 説明する部品に 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>
.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; }
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 }));