本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ナビゲーション

ベルが揺れて通知バッジが弾んで付き、数字が繰り上がって増える

デモ

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

仕様

動作
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 倍に抑えます。バッジは未読という状態を示すだけなので、成功やエラーの色ではなく墨にしています。このデモでは「テスト通知を送る」で届き、ベルを押すと既読になります。ヘッダーの通知、カートの点数、未読のメッセージ数に向いています。

コード

HTML
<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>

CSS
.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; }
}

JavaScript
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")));
});

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

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