デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- CustomEase・CustomWiggle
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
新しい通知の合図は、ベルとバッジの両方に現れます。ベルは上端の近くを支点に左右へ振れて止まり、バッジのまわりには同じ形の輪が広がって消えます。最初の通知ではバッジが弾んで現れ、次からは中の数字が下から繰り上がって、バッジが小さく跳ねます。ベルを押して一覧を開くと既読になり、バッジは縮んで消えます。
ベルの揺れは、ひとつのトゥイーンで作っています。GSAP の CustomWiggle で「振れながら元に戻る」イージングを作り、rotation を16°へ向かわせるトゥイーンにかけると、0.9秒の間に何度も往復してから止まります。揺れのたびにトゥイーンをつなぐ手間がありません。
数字は、ひと文字分の高さだけを見せる要素の中に前の数と新しい数を縦に並べ、その列を yPercent で半分だけ上へ送って入れ替えます。列や段を span で書くときは display: block を指定してください。inline のままだと transform が効かず、数字が動きません。桁が増えるとバッジは横に伸び、100件からは「99+」と表示します。
件数は画面に表示するだけで、スクリーンリーダーにはボタンの aria-label を未読の件数入りの名前に書き換えて伝えます。届いたときは status の役割を持つ見えない領域に知らせの文を書き込み、既読にしたときも短く知らせます。
知らせの強さは、往復の回数と揺れの角度、跳ねる大きさで決まります。控えめにしたいなら、往復を3回ほど、角度を10°ほどに抑えます。
バッジの縁はバーの背景と同じ色の太い border にして、ベルの丸からバッジの形に切り抜いたように見せました。色は成功やエラーを表す色を避け、文字と同じ濃いグレーにしています。伝えたいのは、未読があるという状態だけだからです。
コード
<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")));
});