デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- DrawSVGPlugin
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
通知を画面の右の外に置いておき、押されたら滑り込ませます。少し行き過ぎてから戻るので、勢いよく差し込まれたように見えます。丸いアイコンは最初から出しておき、中のチェックの線だけをあとから描きます。下端の細い線が、消えるまでの残り時間です。
滑り込みは 0.6 秒の back.out(1.3) で、不透明度は 0.2 秒で先に上げます。チェックは 0.3 秒後から 0.35 秒かけて描きます。丸ごと弾ませると、描かれていく線より丸の動きに目が行くので、丸は大きさを変えません。
出る・待つ・消えるを、1 本のタイムラインに並べます。こうすると、重ねているあいだは pause、× は消える直前へ seek して再生するだけで済み、どこまで進んだかを別に覚えておく必要がありません。出ている途中で押されても、同じ動きで引っ込みます。
通知は、見た目だけでは伝わりません。置き場所を aria-live の領域として先にページへ置き、通知はその中へ足します。領域ごとあとから足すと、読み上げは何も言いません。閉じるボタンに焦点が当たっているあいだも、時間を止めます。
表示時間は 2.4 秒です。1 文で読み切れない知らせなら、4 秒ほどまで延ばします。上から下ろす形にするなら、横の移動を縦に替えます。複数を積むときは、消えるときに高さと外側の余白も 0 にすると、下の通知が詰まります。保存の完了・コピーの完了・エラーの知らせに向いています。
コード
<button class="save-button" type="button">変更を保存</button>
<!-- 置き場所は最初からページにあり、通知はこの中へ足す。後から領域ごと足すと読み上げられない -->
<div class="toast-region" role="status" aria-live="polite"></div>
<template id="toast-template">
<div class="toast">
<span class="toast__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="27" height="27" focusable="false">
<!-- pathLength を 1 にしておくと、線の実長に関わらず 0% → 100% で描き切れる -->
<path class="toast__check" d="M5 12.5 L10 17.5 L19 7.5" pathLength="1" />
</svg>
</span>
<p class="toast__text">
<b class="toast__title"></b>
<span class="toast__body"></span>
</p>
<button class="toast__close" type="button" aria-label="通知を閉じる">×</button>
<span class="toast__timer" aria-hidden="true"></span>
</div>
</template>
.toast-region {
--ink: #eeece6; /* 文字とタイマーの線 */
--surface: #262626; /* 通知の面 */
--mute: #a8a6a0; /* 補足の文字 */
--line: rgba(238, 236, 230, 0.14); /* 縁 */
--success: #4f8a64; /* 成功の色 */
position: fixed;
z-index: 100;
right: 24px;
top: 24px;
width: min(400px, calc(100vw - 48px));
}
.toast {
position: relative;
box-sizing: border-box;
padding: 20px 20px 24px;
border: 1px solid var(--line);
border-radius: 4px;
background-color: var(--surface);
color: var(--ink);
overflow: hidden; /* タイマーの線を、角の丸みで切る */
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
column-gap: 16px;
}
.toast + .toast { margin-top: 12px; }
.toast__icon {
display: grid;
place-items: center;
flex: none;
width: 48px;
height: 48px;
border-radius: 50%;
background-color: var(--success);
}
/* 描くのは線だけ。丸は最初から出しておく */
.toast__check {
fill: none;
stroke: #ffffff;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.toast__text { margin: 0; }
.toast__title { display: block; font-size: 17px; font-weight: 700; line-height: 1.3; }
.toast__body { display: block; margin-top: 4px; color: var(--mute); font-size: 13px; line-height: 1.7; }
.toast__close {
align-self: start;
width: 32px;
height: 32px;
margin: -6px -6px 0 0;
padding: 0;
border: 0;
background: none;
color: var(--mute);
font: inherit;
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.toast__close:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* 残り時間の線。左端を基点に縮めるので、origin は左につける */
.toast__timer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
transform-origin: 0 50%;
background-color: var(--ink);
}
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
gsap.registerPlugin(DrawSVGPlugin);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const M = reduceMotion ? 0.02 : 1; // 動きを減らす設定では、出入りだけをほぼ一瞬にする(読む時間は残す)
const SHOW = 2.4; // 表示時間(タイマーの線が縮みきるまで)
const EXIT = 0.45 * M; // 消えるのにかかる時間
const region = document.querySelector(".toast-region");
const template = document.getElementById("toast-template");
export function showToast(title, body) {
const toast = template.content.firstElementChild.cloneNode(true);
toast.querySelector(".toast__title").textContent = title;
toast.querySelector(".toast__body").textContent = body;
region.append(toast); // 置き場所へ足した時点で、読み上げにも流れる
const off = toast.offsetWidth + 48; // 右端の外(縁の線も見えなくなるところ)まで
const notify = gsap
.timeline({ onComplete: () => toast.remove() })
// 不透明度は先に上げ、位置は少し行き過ぎてから戻す
.fromTo(toast, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 * M, ease: "power1.out" }, 0)
.fromTo(toast, { x: off }, { x: 0, duration: 0.6 * M, ease: "back.out(1.3)" }, 0)
// 丸はそのままにして、チェックの線だけを描く
.fromTo(toast.querySelector(".toast__check"), { drawSVG: "0%" }, { drawSVG: "100%", duration: 0.35 * M, ease: "power2.out" }, 0.3 * M)
// 表示時間のあいだ、下端の線を左端へ向けて縮める
.fromTo(toast.querySelector(".toast__timer"), { scaleX: 1 }, { scaleX: 0, duration: SHOW, ease: "none" }, 0.6 * M)
// 縮みきったら、右へ滑りながら消える
.to(toast, { x: off, autoAlpha: 0, duration: EXIT, ease: "power2.in" });
// 読んでいるあいだは止める。離れたら、残りの時間から数えつづける
const hold = () => notify.pause();
const resume = () => notify.play();
toast.addEventListener("pointerenter", hold);
toast.addEventListener("pointerleave", resume);
const close = toast.querySelector(".toast__close");
close.addEventListener("focus", hold);
close.addEventListener("blur", resume);
// 消える所まで飛ばす。出る途中で押されても、同じ動きで引っ込む
close.addEventListener("click", () => notify.seek(notify.duration() - EXIT).play());
}
document.querySelectorAll(".save-button").forEach((button) => {
button.addEventListener("click", () => {
showToast("変更を保存しました", "プロフィールに反映されています。");
});
});