デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- CustomEase・CustomWiggle
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
通知が届くたびに、ベルが上端の近くを支点に振れて止まり、バッジと同じ形の輪が広がって消えます。1 件目はバッジが弾んで付き、2 件目からはバッジの中の数字が下から繰り上がって、バッジが小さく跳ねます。ベルを押して一覧を開くと既読になり、バッジが縮んで消えます。
ベルの揺れは、CustomWiggle で作った「振れながら 0 に戻る」ease を、rotation 0 → 16° の 1 本のトゥイーンにかけて、0.9 秒で 6 往復させます。バッジは 0.5 秒の back.out(3) で 0 から弾ませ、2 件目からは 1.22 倍まで 0.1 秒で膨らませて、0.4 秒で戻します。輪はバッジの大きさから 2.4 倍へ広げながら、0.6 秒で消します。
数字は、overflow: hidden の窓の中に前の数と新しい数の 2 段を縦に並べ、列を yPercent の 0 → −50 へ 0.45 秒で送って入れ替えます。列も段も span で書くなら、display: block にしてください。inline のままでは transform が効かず、数字が動きません。バッジの縁はバーの面の色の太い border にすると、ベルの丸から切り抜いたように見えます。2 桁になるとバッジは横に伸び、100 件からは「99+」と出します。
数字は画面にだけ出し、読み上げには、ボタンの aria-label を「通知(未読2件)」と書き換えて伝えます。届いたときは role="status" の見えない領域に「新しい通知が届きました。未読は2件です。」と書き、既読にしたときも短く知らせます。動きを減らす設定では、ベルを揺らさず、数だけをすぐに替えます。
往復の回数、揺れの角度、跳ねる大きさで、知らせの強さが変わります。控えめにするなら、往復を 3 回ほど、角度を 10° ほどにし、跳ねを 1.1 倍に抑えます。バッジは未読という状態を示すだけなので、成功やエラーの色ではなく墨にしています。このデモでは「テスト通知を送る」で届き、ベルを押すと既読になります。ヘッダーの通知、カートの点数、未読のメッセージ数に向いています。
コード
<header class="app-bar">
<a class="app-bar__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
<nav class="app-bar__nav" aria-label="メイン">
<a href="/" aria-current="page">ホーム</a>
<a href="/reports/">レポート</a>
<a href="/members/">メンバー</a>
</nav>
<div class="app-bar__tools">
<button class="app-bar__icon" type="button" aria-label="検索">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="10.5" cy="10.5" r="6.5" /><path d="M15.5 15.5 20 20" /></svg>
</button>
<!-- 未読の数は aria-label で伝え、届いたことは下の status が読み上げる -->
<button class="app-bar__icon notify" type="button" aria-label="通知">
<svg class="notify__bell" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 3.5c-3.3 0-6 2.7-6 6v4.3l-1.6 2.7h15.2L18 13.8V9.5c0-3.3-2.7-6-6-6z" />
<path d="M9.8 19.3a2.3 2.3 0 0 0 4.4 0" />
</svg>
<!-- 届いたときに広がる輪と、件数のバッジ。数字は窓の中の 2 段(上 = 前の数、下 = 今の数)を上へ送って繰り上げる -->
<span class="notify__mark" aria-hidden="true">
<span class="notify__ping"></span>
<span class="notify__badge"><span class="notify__column"><span>0</span><span>0</span></span></span>
</span>
</button>
<a class="app-bar__avatar" href="/account/" aria-label="アカウント">佐</a>
</div>
</header>
<p class="notify-status" role="status"></p>
<!-- 試しに届けるボタン。実際には、通知が届いたときにベルへ notify を送る(script.js の最後) -->
<button class="notify-test" type="button">テスト通知を送る</button>
.app-bar {
--ink: #262626;
--idle: #6b6b6b; /* 今のページでないリンク */
--line: rgba(38, 38, 38, 0.12); /* バーの縁 */
--surface: #ffffff; /* バーの面。バッジの縁もこの色にして、ベルの丸から切り抜いたように見せる */
--pressed: #f2f1ed; /* 押した瞬間のベルの地 */
--badge: #262626; /* 未読の数は成功やエラーではないので、意味の色ではなく墨 */
--on-badge: #ffffff;
display: flex;
align-items: center;
gap: 32px;
height: 64px;
padding: 0 24px;
border-radius: 4px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
color: var(--ink);
white-space: nowrap;
}
.app-bar a { color: inherit; text-decoration: none; }
.app-bar__logo { flex: none; font-size: 18px; font-weight: 700; letter-spacing: 0.12em; }
.app-bar__nav { display: flex; gap: 24px; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; }
.app-bar__nav a:not([aria-current="page"]) { color: var(--idle); }
.app-bar__tools { display: flex; align-items: center; gap: 16px; margin-left: auto; }
.app-bar__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--surface);
color: inherit;
cursor: pointer;
}
.app-bar svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.app-bar :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* ベルのボタンはアイコンだけなので、文字の色の線の丸にする */
.notify { position: relative; box-shadow: inset 0 0 0 1px var(--ink); }
.app-bar__avatar { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; font-size: 12px; font-weight: 700; }
.app-bar__avatar { background: linear-gradient(135deg, #d2b999, #b27771); } /* 人の写真の代わり(グレージュ → くすみローズ) */
/* 輪とバッジを右上の同じ升に重ねる。輪はバッジと同じ大きさになる */
.notify__mark { position: absolute; top: -6px; right: -6px; display: grid; }
.notify__ping, .notify__badge { grid-area: 1 / 1; border-radius: 999px; visibility: hidden; }
.notify__ping { border: 2px solid var(--badge); }
/* 2 桁になると横に伸びる。数字の窓は overflow: hidden で、1 段ぶんの高さだけ見せる */
.notify__badge {
min-width: 24px;
height: 24px;
box-sizing: border-box;
padding: 0 3px;
border: 3px solid var(--surface);
background: var(--badge);
color: var(--on-badge);
overflow: hidden;
font-size: 11px;
font-weight: 700;
line-height: 18px; /* 高さ 24px から縁 3px を上下に引いた、窓 1 段ぶん */
text-align: center;
font-variant-numeric: tabular-nums;
}
.notify__column, .notify__column span { display: block; } /* inline の span には transform が効かない */
/* 読み上げだけに届ける */
.notify-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* 試しに届ける副ボタン(1px の線の丸帯) */
.notify-test {
--ink: #262626;
margin-top: 48px;
padding: 0 32px;
border: 0;
border-radius: 999px;
background: transparent;
box-shadow: inset 0 0 0 1px var(--ink);
color: var(--ink);
font: inherit; /* ボタンは書体を受け継がないので、ページの書体にそろえる */
font-size: 15px;
font-weight: 600;
line-height: 48px;
letter-spacing: 0.04em;
cursor: pointer;
}
/* 狭い画面では、リンクと検索を隠してロゴを小さくする */
@media (max-width: 600px) {
.app-bar { gap: 16px; padding: 0 16px; }
.app-bar__nav, .app-bar__icon:not(.notify) { display: none; }
.app-bar__logo { font-size: 15px; }
}
import { gsap } from "gsap";
import { CustomEase } from "gsap/CustomEase";
import { CustomWiggle } from "gsap/CustomWiggle";
gsap.registerPlugin(CustomEase, CustomWiggle);
// 振れながら 0 に戻る ease。rotation 0 → 16° の 1 本のトゥイーンにかけると、ベルが揺れて止まる
CustomWiggle.create("bell-ring", { wiggles: 6, type: "easeOut" });
const RING = 16; // 揺れの角度(度)
const MAX = 99; // これを超えたら「99+」と出す
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const status = document.querySelector(".notify-status");
document.querySelectorAll(".notify").forEach((button) => {
const bell = button.querySelector(".notify__bell");
const ping = button.querySelector(".notify__ping");
const badge = button.querySelector(".notify__badge");
const column = button.querySelector(".notify__column");
const [before, after] = column.children;
const color = (name) => getComputedStyle(button).getPropertyValue(name).trim();
const text = (n) => (n > MAX ? `${MAX}+` : String(n));
let count = 0;
let anim = null;
let press = null;
gsap.set(bell, { transformOrigin: "50% 14%" }); // 吊り手のあたりを支点に揺らす
gsap.set(column, { yPercent: -50 }); // 下の段(今の数)を窓に見せておく
gsap.set(badge, { scale: 0, autoAlpha: 0 });
function setCount(n) {
const old = count;
count = n;
button.setAttribute("aria-label", n ? `通知(未読${text(n)}件)` : "通知");
anim?.kill();
if (n === 0) {
anim = gsap.to(badge, { scale: 0, autoAlpha: 0, duration: 0.3, ease: "back.in(2)" });
} else {
before.textContent = text(old);
after.textContent = text(n);
// ベルが振れて止まり、バッジと同じ形の輪が広がって消える
anim = gsap
.timeline()
.fromTo(bell, { rotation: 0 }, { rotation: RING, duration: 0.9, ease: "bell-ring" }, 0)
.set(ping, { autoAlpha: 0.9 }, 0)
.fromTo(ping, { scale: 1 }, { scale: 2.4, duration: 0.7, ease: "power2.out" }, 0)
.to(ping, { autoAlpha: 0, duration: 0.6, ease: "power1.in" }, 0.1);
if (old === 0) {
// 1 件目: バッジが弾んで付く
anim.set(column, { yPercent: -50 }, 0).fromTo(badge, { scale: 0, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, duration: 0.5, ease: "back.out(3)" }, 0);
} else {
// 2 件目から: 前の数が上へ抜け、新しい数が下から入る。バッジは小さく跳ねる
anim
.fromTo(column, { yPercent: 0 }, { yPercent: -50, duration: 0.45, ease: "power3.out" }, 0)
.to(badge, { scale: 1.22, autoAlpha: 1, duration: 0.1, ease: "power2.out" }, 0)
.to(badge, { scale: 1, duration: 0.4, ease: "back.out(3)" }, 0.1);
}
}
// 動きを減らす設定では揺らさず、数だけをすぐに替える
if (reduceMotion) anim.progress(1);
}
// 通知が届いた
button.addEventListener("notify", () => {
setCount(Math.min(count + 1, MAX + 1));
status.textContent = `新しい通知が届きました。未読は${text(count)}件です。`;
});
// 押した(一覧を開いた)ら既読にする。押した瞬間だけ地を一段沈める
button.addEventListener("click", () => {
press?.kill();
press = gsap
.timeline()
.to(button, { backgroundColor: color("--pressed"), duration: 0.08, ease: "none" })
.to(button, { backgroundColor: color("--surface"), duration: 0.3, ease: "none" }, 0.12);
if (count > 0) status.textContent = "通知を既読にしました。";
setCount(0);
// ここで通知の一覧を開く
});
});
// 試しに届ける。実際には、通知が届いたとき(WebSocket の message など)に
// button.dispatchEvent(new CustomEvent("notify")) を送る
document.querySelector(".notify-test")?.addEventListener("click", () => {
document.querySelectorAll(".notify").forEach((button) => button.dispatchEvent(new CustomEvent("notify")));
});