本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
アイコン

ベルのアイコンが揺れ、通知の点が付く

デモ

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

仕様

用途
アイコン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

通知が届くと、ベルの胴が吊り手の上端を支点に振れて止まり、中の舌が少し遅れて逆向きに振れます。同時に右上へ赤い点が付き、点のまわりだけ胴の線が切り抜かれて、点が線に食い込んだように見えます。ベルを押して一覧を開くと、点が縮んで消え、胴が小さくうなずいて既読になります。

揺れは、減衰する正弦 sin(2π·3.5·p)·(1 − p)² を自前のイージングにして、0° → 16° の 1 本のトゥイーンにかけます。0 で始まり、3.5 往復しながら 0 に戻るので、終わりで向きが飛びません。胴の支点は svgOrigin で吊り手の上端に置き、舌は 0.07 秒遅れて同じ形で逆向き(−22°)に振ります。点の半径は 0 → 3、穴の半径は 0 → 4.6 を、どちらも back.out(3) で広げます。

点と穴は拡大ではなく、半径そのもの(attr の r)を動かして同じ中心から広げます。穴は点より 1.6 大きいので、振れて通り過ぎる胴の線も点の手前で途切れます。線の色と太さは胴の g にだけ付けます。svg に付けると mask の中の図形まで受け継ぎ、穴の縁に線幅ぶんの半透明の輪ができて、切り抜いた線が淡く残ります。

点には数を出さず、未読の数は読み上げに届けます。ボタンの aria-label を「通知(未読2件)」と書き換え、届いたときは role="status" の領域に「新しい通知が届きました。未読は2件です。」と書きます。既読にしたら名前を「通知」に戻します。動きを減らす設定では揺らさず、点だけをすぐに付けます。

振れ幅と往復の回数、揺れが収まるまでの時間で、にぎやかさが変わります。往復を減らして振れ幅を小さくすると、控えめな知らせになります。デモは、ヘッダーの 24px のベルと、それを 4 倍にしたカードのベルを同じ動きで見せています。ヘッダーの通知、受信箱、リマインダーに向いています。

コード

HTML
<!-- 名前と未読の数は aria-label で伝え、届いたことは下の status が読み上げる -->
<button class="bell" type="button" aria-label="通知">
  <svg class="bell__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
    <!-- 点のまわりだけ胴の線を切り抜く穴。半径は script.js が点と一緒に広げる -->
    <mask class="bell__mask" id="bell-gap" maskUnits="userSpaceOnUse" x="-8" y="-8" width="40" height="40">
      <rect x="-8" y="-8" width="40" height="40" fill="#fff" />
      <circle class="bell__gap" cx="17.6" cy="6" r="0" fill="#000" />
    </mask>
    <g mask="url(#bell-gap)">
      <!-- 胴は吊り手の上端 (12, 3) を支点に振れる。舌は胴の中に置き、遅れて逆へ振れる -->
      <g class="bell__body">
        <path d="M12 2.8v1.7" />
        <path d="M6 10.5a6 6 0 0 1 12 0v4.2l1.6 2.3H4.4L6 14.7z" />
        <path class="bell__clapper" d="M10 20a2 2 0 0 0 4 0" />
      </g>
    </g>
    <circle class="bell__dot" cx="17.6" cy="6" r="0" />
  </svg>
</button>
<p class="bell-status" role="status"></p>

<!-- 試しに鳴らすボタン。実際には、通知が届いたときにベルへ notify を送る(script.js の最後) -->
<button class="bell-test" type="button">テスト通知を送る</button>

CSS
.bell {
  --ink: #262626;     /* 線と枠 */
  --surface: #ffffff; /* ボタンの地 */
  --hover: #f2f1ed;   /* 重ねたときの地 */
  --dot: #c4553d;     /* 通知の点(エラーの赤) */
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background-color: var(--surface);
  box-shadow: inset 0 0 0 1px var(--ink); /* アイコンだけのボタンなので、線の丸で押せる範囲を見せる */
  color: var(--ink);
  cursor: pointer;
}

.bell:hover { background-color: var(--hover); }
.bell:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.bell__icon { width: 24px; height: 24px; overflow: visible; }

/* 線の指定は胴にだけ付ける。svg に付けると mask の中の図形まで受け継ぎ、穴の縁に淡い輪が残る */
.bell__body {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.bell__dot { fill: var(--dot); }

/* 読み上げにだけ届ける文 */
.bell-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.bell-test {
  height: 48px;
  margin-top: 24px;
  padding: 0 32px;
  border: 0;
  border-radius: 999px;
  background-color: #262626;
  color: #ffffff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.bell-test:focus-visible { outline: 2px solid #262626; outline-offset: 4px; }

JavaScript
import { gsap } from "gsap";

const SWING = 16; // 胴の振れ幅(度)
const SWINGS = 3.5; // 揺れの往復の回数
const DOT_R = 3; // 点の半径(24 のグリッドで)
const GAP_R = 4.6; // 点のまわりで線を切り抜く半径。点より 1.6 大きいので、振れる胴の線が点の手前で途切れる
// 減衰する正弦。0 で始まり、振れながら 0 に戻る(rotation 0 → SWING のトゥイーンにかける)
const swing = (p) => Math.sin(p * Math.PI * 2 * SWINGS) * (1 - p) ** 2;
const nod = (p) => Math.sin(p * Math.PI * 3) * (1 - p) ** 2; // 既読のときの小さなうなずき
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const status = document.querySelector(".bell-status");

document.querySelectorAll(".bell").forEach((bell, i) => {
  const mask = bell.querySelector(".bell__mask");
  const body = bell.querySelector(".bell__body");
  const clapper = bell.querySelector(".bell__clapper");
  const dot = bell.querySelector(".bell__dot");
  const gap = bell.querySelector(".bell__gap");
  // ベルがページに何個あっても穴を取り違えないように、mask の id を付け直す
  mask.id = `bell-gap-${i + 1}`;
  bell.querySelector("g[mask]").setAttribute("mask", `url(#${mask.id})`);

  let unread = 0;
  let anim = null;
  const label = () => bell.setAttribute("aria-label", unread ? `通知(未読${unread}件)` : "通知");

  // 届いた: 胴が振れて止まり、舌が遅れて逆へ振れる。点と穴が一緒に広がる
  const ring = () => {
    unread += 1;
    label();
    status.textContent = `新しい通知が届きました。未読は${unread}件です。`;
    anim?.kill();
    anim = gsap
      .timeline()
      .to(dot, { attr: { r: DOT_R }, duration: 0.45, ease: "back.out(3)" }, 0.12)
      .to(gap, { attr: { r: GAP_R }, duration: 0.45, ease: "back.out(3)" }, 0.12);
    // 動きを減らす設定では揺らさず、点だけをすぐに付ける
    if (reduceMotion) {
      anim.progress(1);
      return;
    }
    anim
      .fromTo(body, { rotation: 0, svgOrigin: "12 3" }, { rotation: SWING, svgOrigin: "12 3", duration: 1.3, ease: swing }, 0)
      .fromTo(clapper, { rotation: 0 }, { rotation: -SWING * 1.4, transformOrigin: "50% -150%", duration: 1.3, ease: swing }, 0.07);
  };

  // 押した(一覧を開いた)ら既読: 点と穴が縮んで消え、胴が小さくうなずく
  bell.addEventListener("click", () => {
    unread = 0;
    label();
    anim?.kill();
    anim = gsap
      .timeline()
      .to([dot, gap], { attr: { r: 0 }, duration: 0.28, ease: "back.in(2)" }, 0)
      .to(clapper, { rotation: 0, duration: 0.2, ease: "power2.out" }, 0);
    if (reduceMotion) anim.progress(1);
    else anim.fromTo(body, { rotation: 0, svgOrigin: "12 3" }, { rotation: -8, svgOrigin: "12 3", duration: 0.6, ease: nod }, 0);
    // ここで通知の一覧を開く
  });

  bell.addEventListener("notify", ring);
});

// 試しに鳴らす。実際には、通知が届いたとき(WebSocket の message など)に
// bell.dispatchEvent(new CustomEvent("notify")) を送る
document.querySelector(".bell-test")?.addEventListener("click", () => {
  document.querySelectorAll(".bell").forEach((bell) => bell.dispatchEvent(new CustomEvent("notify")));
});

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

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