GODRICH

トースト通知 UI 9選 — 積み上がるスタック実装

トースト通知 ui とは、画面の隅に一瞬だけ出て消える知らせをどう積み、どう片づけるかを決める仕組みのことです。9つともライブラリなしでバニラ JS と SCSS だけで動き、最初の1つは新しい通知が前のカードを押し上げる動きだけを扱います。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つの並びは人気順ではなく、通知1件が画面に出てから消えるまでの時間の流れに沿っています。まず入ってきて席を取り(01)、何が起きたのかを色とアイコンで名乗り(02)、残り時間を見せながら待ちます(03)。次は片づける側で、指で払って飛ばし(04)、消しすぎたものを戻し(05)、結果が遅れて届く処理は同じ席のまま答えを差し替えます(06)。最後の3つは一度に何件も届いたときの話で、あふれたら束ねて畳み(07)、出す場所を6か所から選び(08)、画面を見ない人のために読み上げの順序を分けます(09)。骨組みは9つとも共通です。スタックは高さを決めた箱の中に収まり、カードの上下移動はほとんど transform が受け持ちます。カードを topmargin で押すと、積み上がるたびにブラウザーがレイアウトを測り直すからです。

01押し上がるスタック

新しいカードは一覧のいちばん下に入り、その上にいたカードが1段ずつ上がります。上がる途中が飛んで見えないように、差し込む前の位置を先に測り、差し込んだあとに元の位置へ貼り直してから1フレーム遅れて放します。この4手順が FLIP で、ブラウザーが動きとして見るのは最後の1手だけです。

getBoundingClientRecttranslateYrequestAnimationFrame
var before = [].map.call(list.children, function (el) { return el.getBoundingClientRect().top; });
list.insertBefore(el, list.firstChild);
[].forEach.call(list.children, function (node, i) {
  if (!i) return;
  var d = before[i - 1] - node.getBoundingClientRect().top; // 元の位置 − 今の位置 = 戻す距離
  node.style.transition = 'none';
  node.style.transform = 'translateY(' + d + 'px)';
});
requestAnimationFrame(function () { // 1フレーム遅らせないと、戻した位置が始点として見られない
  [].forEach.call(list.children, function (node, i) {
    if (!i) return;
    node.style.transition = '';
    node.style.transform = '';
  });
});

02種類ごとのアイコンと色

成功・警告・エラー・お知らせの4種類が1枚のカードの中に重なっていて、カードの data-kind の値ひとつがどの顔を出すかを決めます。自動再生では4つの顔が 0.5 秒ごとに入れ替わりますが、切り替わる瞬間を steps(1, end) で断ち切り、2つの顔が半透明で重なるコマをなくしました。負の animation-delay を変えるだけで、同じキーフレーム1つで4つの顔を回せます。

data-kindsteps(1, end)currentColor
.tk.is-demo .tk__face {
  animation-name: tk-face;
  animation-duration: $dur-loop;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}
.tk.is-demo .tk__face[data-kind="ok"]   { animation-delay: 0s; }
.tk.is-demo .tk__face[data-kind="warn"] { animation-delay: -1.5s; }
.tk.is-demo .tk__face[data-kind="bad"]  { animation-delay: -1s; }
.tk.is-demo .tk__face[data-kind="info"] { animation-delay: -0.5s; }
@keyframes tk-face {
  0%   { opacity: 1; }
  25%  { opacity: 0; }
  100% { opacity: 0; }
}

03残り時間バーと一時停止

下のバーは scaleX で縮み、軸を左に置いているので右端から減っていきます。マウスを乗せるかキーボードの焦点が入ると、バーには animation-play-state: paused をかけ、閉じるタイマーはいったん消します。離れたときに、経過分を引いた残り時間でタイマーを掛け直します。どちらか片方だけ止めると、バーは止まったまま通知だけが閉じてしまいます。

scaleXanimation-play-statetransform-origin
.pb__fill {
  display: block;
  height: 100%;
  background: $color;
  transform-origin: left center;
  transform: scaleX(1);
}
.pb__toast.is-on .pb__fill {
  animation-name: pb-drain;
  animation-duration: 4200ms;
  animation-timing-function: linear;
  animation-iteration-count: 1;
  animation-fill-mode: forwards;
}
.pb__toast.is-hold .pb__fill { animation-play-state: paused; }
@keyframes pb-drain {
  0%   { transform: scaleX(1); }
  100% { transform: scaleX(0); }
}

04スワイプで飛ばす

押したまま横に引くとカードが指にそのまま追従し、離したときの移動量が 72px を超えていればその向きへ 420px 飛んで閉じます。足りなければ移動量を 0 に戻して元の位置へ弾んで戻ります。押した瞬間に setPointerCapture を呼んでおかないと、指がカードの外へ出た時点で移動イベントが途切れます。

setPointerCapturepointermovetranslateX(var(--dx))
card.addEventListener('pointerdown', function (e) {
  root.classList.remove('is-demo');
  down = true; x0 = e.clientX; dx = 0;
  card.setPointerCapture(e.pointerId); // 指がカードの外へ出ても move が届き続ける
  card.classList.add('is-drag');
});
card.addEventListener('pointermove', function (e) {
  if (!down) return;
  dx = e.clientX - x0;
  set(dx);
});
card.addEventListener('pointerup', function () {
  if (!down) return;
  down = false;
  card.classList.remove('is-drag');
  if (Math.abs(dx) > OUT) fling(dx < 0 ? -1 : 1); else set(0);
});

05本当に元に戻すボタン

消した行はすぐに取り除かず、grid-template-rows1fr から 0fr へ畳んで隠すだけにしています。元に戻すボタンを押したら予約してある削除を clearTimeout で取り消し、畳みを解けばよいので、消した中身をどこかに控えておく必要がありません。トーストが出る場所は高さを先に確保してあるので、トーストが現れても上の一覧はずれません。

clearTimeoutgrid-template-rowsaria-live
function drop(row) {
  root.classList.remove('is-demo');
  if (pending) commit();
  pending = row;
  row.classList.add('is-gone');
  toast.classList.add('is-on');
  clearTimeout(timer);
  timer = setTimeout(commit, 4000);
}
function undo() {
  clearTimeout(timer);
  if (pending) pending.classList.remove('is-gone');
  pending = null;
  toast.classList.remove('is-on');
}

06読み込みから結果へ変わるトースト

同じカードの中で回っているリングが、処理の終わった瞬間に成功か失敗の印へ切り替わります。リングは 1 秒で一周する rotate を無限に回し、切り替え点はコマとコマの間に置いた steps(1, end) のカットなので、2つのアイコンが半分ずつ見える区間がありません。待っている間はカードの aria-busytrue になり、答えが出ると false に変わります。

rotatesteps(1, end)aria-busy
.pm__spin {
  animation-name: pm-spin;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}
@keyframes pm-spin {
  0%   { rotate: 0deg; }
  100% { rotate: 360deg; }
}
@keyframes pm-load {
  0%   { opacity: 1; }
  35%  { opacity: 0; }
  56%  { opacity: 1; }
  77%  { opacity: 0; }
  89%  { opacity: 1; }
  100% { opacity: 1; }
}

07三件を超えると畳まれる束

カードはそれぞれ自分の順番を --i に持っていて、畳んだ形と開いた形の違いは、その数に掛ける間隔と縮尺だけです。畳んだときは 12px ずつずれて scale が少しずつ小さくなりデッキのように見え、開くと 36px の階段になって全部が同じ大きさに戻ります。重なり順は z-index: calc(9 - var(--i)) の一行が引き受けます。

aria-expanded--iscale
.cg__item {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  margin: 0;
  padding: 0;
  z-index: calc(9 - var(--i));
  transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04));
  transition: transform $duration $easing;
  cursor: pointer;
}
.cg__list.is-open .cg__item { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }

08六か所の表示位置切り替え

窓の data-pos の値ひとつが通知の束の insettransform を同時に変え、上下3か所ずつ計6か所のどれかへ寄せます。中央の位置は inset の左の値を 50% にして translate(-50%) と、右側の位置は calc(100% - 8px) にして translate(-100%) と組み合わせてあるので、束の幅を知らなくても計算が合います。2つの値を同じ時間で移すから、位置が変わるときに飛ばずに滑ります。

data-posinsetaria-pressed
.ps__toasts {
  position: absolute;
  inset: calc(100% - 8px) auto auto calc(100% - 8px);
  display: flex;
  flex-direction: column;
  gap: $sp-1;
  width: 148px;
  transform: translate(-100%, -100%);
  transition: inset $duration $easing, transform $duration $easing;
}
.ps__screen[data-pos="tl"] .ps__toasts { inset: 30px auto auto 8px;               transform: translate(0, 0); }
.ps__screen[data-pos="tc"] .ps__toasts { inset: 30px auto auto 50%;               transform: translate(-50%, 0); }
.ps__screen[data-pos="tr"] .ps__toasts { inset: 30px auto auto calc(100% - 8px);  transform: translate(-100%, 0); }
.ps__screen[data-pos="br"] .ps__toasts { inset: calc(100% - 8px) auto auto calc(100% - 8px); transform: translate(-100%, -100%); }

09読み上げ順を分けるライブリージョン

左のレーンは読み上げ中の文が終わるのを待ってから読まれ、右のレーンは読んでいる途中でも割り込みます。違いは役割名と aria-live の値の組み合わせだけなので、エラーだけを右へ送れば残りの通知が読み上げのじゃまをしません。aria-atomic="true" を付けると、変わった部分だけでなく文全体をもう一度読み直してくれます。

role="alert"aria-live="assertive"aria-atomic
<p class="lr__card" role="status" aria-live="polite" aria-atomic="true" aria-label="丁寧な通知のレーン">
  <span class="lr__dot" aria-hidden="true"></span>
  <span class="lr__msg">メールを送りました</span>
</p>
<p class="lr__card" role="alert" aria-live="assertive" aria-atomic="true" aria-label="緊急の通知のレーン">
  <span class="lr__dot" aria-hidden="true"></span>
  <span class="lr__msg">決済が拒否されました</span>
</p>

どこで壊れるか — 落とし穴

トーストは消えることが仕事の部品です。そのため9つを 2 秒のループで回したところ、ほかの記事では出なかった問題が見つかりました。このサイトの代表画像は 24 コマのうち「直前のコマといちばん大きく変わった1枚」から選ばれるのですが、トーストのデモでいちばん大きく変わるのは、いつでもカードが消える瞬間です。03 の最初の版がまさにそれで、代表画像はオレンジの地にボタンが1つ残っただけの空白になり、通知が出ている場面は1枚も候補になりませんでした。直し方は単純で、ループの中ではカードを消さないことにしました。バーが尽きるとカードが一度沈み、次の通知が来たようにバーがまた満ちます。閉じる動き自体はボタンと自動タイマーで今も動いています。

2つめは消え方を置く位置です。2 秒を 24 コマに割ると 4.1667% ごとに1枚ずつ拾われるので、消え方をその区切りにまたがせると半透明の幽霊カードが写ります。そこで 06・09 の状態の入れ替えはコマとコマの間(35%・45%・56%・77%・89%)に steps(1, end) のカットとして置き、0.5 秒ごとに顔が替わる 02 も同じ steps(1, end) で中間のコマそのものをなくしました。01 はカードが3枚あるので事情が違います。3枚を一度に消すとその空白がまた代表になるため、78%・82%・86% で1枚ずつ断ち切って抜いています。3つめは大きさです。絶対配置したトーストは親の高さの計算に入らないため、舞台は無事に見えても文書の高さだけが静かに伸びます。だからカードを絶対配置で動かすデモでは、スタックを高さの決まった箱に入れ、はみ出す分を overflow: hidden で切り落としました。320×200 の舞台では余白を引くと縦に使えるのは 174px だけなので、狭い画面ではカードの間隔を 44px から 34px へ縮め、箱の高さも合わせて下げています。9つ分のソースが入った圧縮ファイルのパスワードは qh4e8nxm で、いま見えている9つの SCSS と React 版がここで直した値のまま入っています。

アクセシビリティ

動きを減らす設定(prefers-reduced-motion: reduce)が入っていると、9つとも自動再生だけを止め、いまの状態は画面に残します。01 のカードは滑る過程なしに3つの席へそのまま立ち、03 のバーは減るのをやめ、09 の読み上げメーターは高さを固定したまま、どちらのレーンが生きているかは色で示し続けます。押して閉じる操作もマウス任せにはしていません。

項目 マウス キーボード 読み上げ
01 スタック ボタンを押す Enter · Space aria-live="polite" で新しい行だけ
03 進行バー 乗せると止まる Tab の焦点でも止まる 閉じるボタンに aria-label
04 スワイプ 引いて離す 左右の矢印でその向きへ 後ろのカードが残り空白にならない
05 元に戻す ごみ箱を押す Tab · Enter role="status" で削除を知らせる
06 プロミス ボタンを押す Enter · Space aria-busy で進行中かどうか
07 束 カードかボタンを押す Enter · Space aria-expanded
08 位置切り替え 位置ボタンを押す Tab · Enter aria-pressed · 位置ごとに aria-label
09 2つのレーン ボタンを押す Enter · Space role="status"role="alert" を分ける

2種類のライブリージョンのうちなぜ role="status" を既定にすべきかは、MDN の status ロールの解説にまとまっています。04 が使うポインターの捕捉も MDN の setPointerCapture の解説に条件が書かれていて、捕まえておかないと指がカードの縁を越えた瞬間に移動イベントが途切れ、カードが途中で止まります。同じように何かを知らせる部品はお知らせカテゴリに、人が押さなくても自分で動く部品は自動実行カテゴリに集めてあります。

よくある質問

通知が同時に5件届いたらどうしますか

画面に置く件数を決めておき、あふれたら古いものから抜くか(01 は3枚で切ります)、07 のように束ねて畳みます。どちらも「何件まで」という数字ひとつを変えれば済むように書いてあります。件数を制限しないと縦に伸びて、画面の下にあるボタンを覆ってしまいます。

自動で閉じるまでの時間は何秒がよいですか

03 は 4.2 秒にしました。読む量と1行の長さによりますが、操作ボタンの付いた通知は押す時間が要るので 05 のように4秒以上取り、マウスを乗せたら止まるようにしておくほうが安全です。やり直せないエラーは、そもそも自動で閉じない側をおすすめします。

この9つをトーストのライブラリの代わりに使えますか

1つの画面で通知を出す場所が1か所、種類が4〜5種類なら、そのまま使っても無理はありません。スタックの管理も自動で閉じる処理も元に戻すもライブリージョンもここに入っていて、依存がないからです。大きなアプリのどこからでも呼びたい場合やサーバーのイベントとつなぐ場合は、ライブラリのキュー管理は残し、このマークアップを描画の差し込み口に置いて見た目だけ持っていくほうが向いています。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。