デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
吹き出しを、下端の矢印の先を起点に小さく縮めて隠しておき、ポインタが重なったら弾ませながら膨らませます。離れたら、同じタイムラインを速めに逆再生して消します。ボタンの地と色も、重なっているあいだだけ明るくします。
重ねてから 0.2 秒待って出します。待たずに出すと、ツールバーの上を横切っただけで吹き出しが次々に出て、うるさく見えます。大きさは 0.5 倍から、位置は 10px 下から、0.45 秒の back.out(2.6) で少し行き過ぎてから収まります。消えるときは 2.5 倍の速さでたどるので、隣のボタンへ移ったときに前の吹き出しが残りません。
拡大の起点は transform-origin で矢印の先に置きます。既定の中央のままだと、吹き出しが宙で膨らみ、矢印の先がボタンから離れたり近づいたりします。横の中央そろえを CSS の transform で書くと、GSAP がそれを px の移動量として読み込み、幅が変わったときにずれるので、xPercent に -50 を渡します。
吹き出しは role="tooltip" にして、ボタンの aria-labelledby で結びます。アイコンだけのボタンでは、吹き出しの文がそのままボタンの名前になるからです。説明として結んだうえでボタンにも名前を付けると、同じ語が 2 度読み上げられます。キーボードで焦点が当たったときは待たずに出し、焦点が外れたら消します。Esc でも消せるようにし、ボタンと吹き出しを 1 つの要素で包んで、吹き出しの上へポインタを動かしても消えないようにします。
弾みを抑えたいときは、行き過ぎの強さを 1.5 ほどに下げ、始まりの大きさを 0.8 倍ほどに寄せます。待つ時間は 0.1〜0.3 秒のあいだで決めます。短すぎると横切っただけで出て、長すぎると反応が鈍く感じられます。書式のツールバー・アイコンだけのボタン・グラフの点の説明に向いています。
コード
<div class="format-bar" role="group" aria-label="文字の書式">
<!-- ボタンと吹き出しを 1 つに包み、包みに重なっている間は出したままにする(吹き出しの上へ動かしても消えない) -->
<span class="tip">
<button class="tip__button" type="button" aria-labelledby="tip-bold">
<span class="tip__glyph" aria-hidden="true">B</span>
</button>
<span class="tip__bubble" id="tip-bold" role="tooltip">太字<kbd>⌘B</kbd></span>
</span>
<span class="tip">
<button class="tip__button" type="button" aria-labelledby="tip-italic">
<span class="tip__glyph tip__glyph--italic" aria-hidden="true">I</span>
</button>
<span class="tip__bubble" id="tip-italic" role="tooltip">斜体<kbd>⌘I</kbd></span>
</span>
<span class="tip">
<button class="tip__button" type="button" aria-labelledby="tip-link">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<g transform="rotate(-45 12 12)" fill="none" stroke="currentColor" stroke-width="2.4">
<rect x="2.5" y="8.5" width="11" height="7" rx="3.5"/>
<rect x="10.5" y="8.5" width="11" height="7" rx="3.5"/>
</g>
</svg>
</button>
<span class="tip__bubble" id="tip-link" role="tooltip">リンクを挿入<kbd>⌘K</kbd></span>
</span>
</div>
.format-bar {
--surface: #262626; /* ツールバーの面 */
--ink: #eeece6; /* 重ねたときのアイコンと、吹き出しの地 */
--idle: #a8a6a0; /* 休んでいるアイコン */
--hover: rgba(238, 236, 230, 0.1); /* 重ねたときのボタンの地 */
--line: rgba(238, 236, 230, 0.14); /* ツールバーの縁 */
--tip-ink: #111111; /* 吹き出しの文字 */
--arrow: 6px; /* 吹き出しの矢印の高さ */
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 4px;
background-color: var(--surface);
}
.tip {
position: relative;
display: inline-flex;
}
.tip__button {
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: 4px;
background-color: transparent;
color: var(--idle);
font: inherit;
font-size: 18px; /* font: inherit の後に書く(先だと打ち消される) */
font-weight: 800;
cursor: pointer;
transition: background-color 0.15s linear, color 0.15s linear;
}
.tip:hover .tip__button,
.tip__button:focus-visible {
background-color: var(--hover);
color: var(--ink);
}
.tip__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.tip__glyph--italic { font-style: italic; font-weight: 700; }
/* 吹き出し。ボタンの真上に置き、矢印の先を拡大の起点にする(横の中央そろえは JS の xPercent) */
.tip__bubble {
position: absolute;
left: 50%;
bottom: calc(100% + var(--arrow) + 6px);
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
background-color: var(--ink);
color: var(--tip-ink);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
white-space: nowrap;
transform-origin: 50% calc(100% + var(--arrow));
visibility: hidden; /* 出し入れは GSAP の autoAlpha */
}
/* 矢印(吹き出しと同じ地の色の三角)と、ボタンとのすき間を埋める透明な橋 */
.tip__bubble::after,
.tip__bubble::before {
content: "";
position: absolute;
top: 100%;
}
.tip__bubble::after {
left: calc(50% - var(--arrow));
border: var(--arrow) solid transparent;
border-bottom: 0;
border-top-color: var(--ink);
}
.tip__bubble::before { left: 0; right: 0; height: calc(var(--arrow) + 6px); }
.tip__bubble kbd {
padding: 2px 6px;
border-radius: 4px;
background-color: rgba(17, 17, 17, 0.08);
color: #6b6b6b;
font: inherit;
letter-spacing: 0.02em;
}
@media (prefers-reduced-motion: reduce) {
.tip__button { transition: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const DELAY = 0.2; // 重ねてから出るまで。通り過ぎただけのボタンでは出さない
const HIDE_SPEED = 2.5; // 消えるときは、出るときの 2.5 倍の速さで逆にたどる
document.querySelectorAll(".tip").forEach((tip) => {
const button = tip.querySelector(".tip__button");
const bubble = tip.querySelector(".tip__bubble");
// 横の中央そろえは xPercent で持たせる。CSS の transform に書くと、GSAP が px に読み替えてずれる
gsap.set(bubble, { xPercent: -50 });
const pop = gsap
.timeline({ paused: true })
.fromTo(bubble, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.15, ease: "power1.out" }, 0)
// 矢印の先(transform-origin)を起点に、少し下から弾みながら膨らむ
.fromTo(bubble, { scale: 0.5, y: 10 }, { scale: 1, y: 0, duration: 0.45, ease: "back.out(2.6)" }, 0);
let wait;
const show = (delay) => {
wait?.kill();
wait = gsap.delayedCall(delay, () => pop.timeScale(SPEED).play());
};
const hide = () => {
wait?.kill();
pop.timeScale(SPEED * HIDE_SPEED).reverse();
};
// キーボードで焦点を当てているか(押して付いた焦点は数えない)
const keyboardFocus = () => button.matches(":focus-visible");
tip.addEventListener("pointerenter", () => show(DELAY));
tip.addEventListener("pointerleave", () => keyboardFocus() || hide());
button.addEventListener("focus", () => keyboardFocus() && show(0)); // キーボードでは待たずに出す
button.addEventListener("blur", hide);
// Esc で消せるようにする(重ねたままでも、焦点を当てたままでも)
document.addEventListener("keydown", (event) => event.key === "Escape" && hide());
});