本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

トーストの通知が右上から滑り込み、タイマーが尽きると消える

デモ

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

仕様

動作
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 にすると、下の通知が詰まります。保存の完了・コピーの完了・エラーの知らせに向いています。

コード

HTML
<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>
CSS
.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);
}
JavaScript
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("変更を保存しました", "プロフィールに反映されています。");
  });
});

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

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