デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- 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 倍にしたカードのベルを同じ動きで見せています。ヘッダーの通知、受信箱、リマインダーに向いています。
コード
<!-- 名前と未読の数は 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>
.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; }
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")));
});