GODRICH

トースト通知 css アニメーション9選 — JS最小・CSSだけ

トースト通知 css は、画面の隅に短く現れて自分で消えていく通知カードを、JavaScriptをほぼ使わずtransformとopacityだけで動かす手法です。この記事では9種類を実装し、コードは6〜25行の断片として載せています。

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

9つは目立たせ方の順ではなく、画面に現れてから消えるまでの流れで並べました。登場の型を変えた3つ(01〜03)、閉じ方や消えるきっかけを変えた3つ(04〜06)、状態や環境に応じて見た目を変える3つ(07〜09)という並びです。

01右からスライドイン

カードは画面右外のtranslateX(140%)から0まで滑り込み、そのまま少し止まってから同じ方向へ抜けていきます。実際の表示は0.6秒で、色を示す左側のバーだけは別要素として重ねています。保存やアップロードが終わったことを画面の隅で知らせる、デスクトップ向けの基本形です。

translateX0.6초코너 고정
.toast {
  transform: translateX(140%);
  opacity: 0;
}
.toast.is-demo { animation: slide-loop $dur-loop $easing infinite; }

@keyframes slide-loop {
  0%   { transform: translateX(140%); opacity: 0; }
  18%  { transform: translateX(0); opacity: 1; }
  78%  { transform: translateX(0); opacity: 1; }
  100% { transform: translateX(140%); opacity: 0; }
}

02Popover APIトースト

popover属性とpopovertargetボタンだけで開閉し、ESCや外側クリックでの終了もJSなしで手に入ります。既定では画面中央に固定されるUAスタイルをposition: fixedで上書きし、右上の角に表示させています。ネットワーク状態のようなシステム通知や、軽量なウィジェットに向いています。

popoverpopovertargetshowPopover()
<div id="toast" class="toast" popover role="status" aria-live="polite">
  <span class="toast__dot" aria-hidden="true"></span>
  <p class="toast__title">接続済み</p>
  <button popovertarget="toast" popovertargetaction="hide" aria-label="通知を閉じる">
    <svg viewBox="0 0 24 24" width="14" height="14">
      <line x1="5" y1="5" x2="19" y2="19"/>
      <line x1="19" y1="5" x2="5" y2="19"/>
    </svg>
  </button>
</div>
<script>document.getElementById('toast').showPopover();</script>

033件スタック

3枚のカードにはインラインの--i(0・1・2)が振られていて、普段はtranslateY(calc(var(--i)*-6px))とscaleでわずかにずれて重なっています。hoverまたはfocus-within-22px・scale(1)まで開き、3件がそれぞれ読めるようになります。短時間にイベントが重なるダッシュボードの通知トレイに向いています。

CSS 변수 --ihover·focus-within덱 펼치기
.stack__item {
  position: absolute; inset: 0;
  transform: translateY(calc(var(--i) * -6px))
             scale(calc(1 - var(--i) * .05));
  opacity: calc(1 - var(--i) * .22);
}
.stack:hover .stack__item,
.stack:focus-within .stack__item {
  transform: translateY(calc(var(--i) * -22px)) scale(1);
  opacity: 1;
  transition: transform $duration $easing, opacity $duration $easing;
}

04残り時間バー

下端の進行バーはscaleX(1)からscaleX(0)へ等速で縮み、なくなる瞬間と同じタイミングでトースト本体がopacitytranslateY(-8px)で消えます。実際の表示は5秒で、進行バーのanimationendをきっかけにJSがトーストを取り除きます。アップロードや送信の完了のように、決まった時間で自動的に閉じる通知に使います。

scaleXlinearanimationend
.toast__bar {
  position: absolute; left: 0; bottom: 0;
  width: 100%; height: 4px;
  background: $color;
  transform-origin: left;
}
.toast.is-demo .toast__bar { animation: bar-shrink $dur-loop linear infinite; }

@keyframes bar-shrink {
  0%   { transform: scaleX(1); }
  82%  { transform: scaleX(0); }
  100% { transform: scaleX(0); }
}

05元に戻すトースト

削除メッセージ横の「元に戻す」ボタンを押すとJSのclearTimeoutでタイマーが止まり、文言が取り消し済みに変わります。押さないまま4秒経つとtransform-origin: topからscaleY(1)が0まで縮み、高さそのものは動かさずに畳まれて消えます。確認ダイアログの代わりに、リストやメール削除の取り消し操作に使います。

scaleYclearTimeout4초
document.querySelectorAll('.toast').forEach(function (t) {
  var btn = t.querySelector('.toast__undo');
  var msg = t.querySelector('.toast__text');
  var timer = setTimeout(function () {
    t.classList.add('is-gone');
  }, 4000);
  btn.addEventListener('click', function () {
    clearTimeout(timer);
    msg.textContent = '取り消しました';
    t.classList.add('is-cancelled');
  });
});

06スワイプで閉じる

ポインターを押したまま動かすとpointermoveが移動距離を--dxに書き込み、translateX(var(--dx))でカードがそのまま追従します。指を離した位置が80pxを超えていればその方向へ滑り出て閉じ、超えなければ0へ戻ります。touch-action: pan-yで縦スクロールを残しているので、モバイルのチャット通知に向いています。

pointermovetranslateX(var(--dx))80px 문턱
el.addEventListener('pointerdown', function (e) {
  x0 = e.clientX;
  dragging = true;
  el.setPointerCapture(e.pointerId);
});
el.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  dx = e.clientX - x0;
  el.style.setProperty('--dx', dx + 'px');
});
el.addEventListener('pointerup', function () {
  dragging = false;
  if (Math.abs(dx) > 80) {
    el.classList.add(dx > 0 ? 'is-swiped-right' : 'is-swiped-left');
  } else {
    el.style.setProperty('--dx', '0px');
  }
});

07成功アイコン

無彩色のリングと状態色のリング+チェックという2枚のSVGをgrid-area: 1 / 1で重ね、opacityのクロスフェードで入れ替えます。チェック線は同時にstroke-dashoffsetが100から0へ動いて描かれます。$colorをエラー色に替えるだけで、そのまま失敗バッジになります。

opacity 크로스페이드stroke-dashoffset상태색 변수
.badge__layer { grid-area: 1 / 1; }
.badge__neutral { opacity: .3; }
.badge__colored { opacity: 0; }
.badge__check { stroke-dasharray: 100; stroke-dashoffset: 100; }

.toast.is-demo .badge__neutral { animation: fade-out $dur-loop $easing infinite; }
.toast.is-demo .badge__colored { animation: fade-in $dur-loop $easing infinite; }
.toast.is-demo .badge__check   { animation: draw $dur-loop $easing infinite; }

08モバイル下部トースト

画面下端に固定し、bottomenv(safe-area-inset-bottom, 0px)を足してノッチやホームインジケーターを避けます。translateY(140%)から0への移動とopacityが同時に動き、ピル型の一行バーが持ち上がって収まります。ハンドル付きのボトムシートと違い、一行だけ表示してタイマーで自動的に閉じます。

safe-area-inset-bottomtranslateY$r-pill
.toast {
  position: fixed;
  left: 16px; right: 16px;
  bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
  margin: 0 auto;
  max-width: 340px;
  border-radius: 999px;
  transform: translateY(140%);
  opacity: 0;
}
.toast.is-demo { animation: bottom-loop $dur-loop $easing infinite; }

09通知バッジ

ベルの<g>transform-box: fill-boxtransform-origin: 50% 0%を与え、先端を軸に左右へ減衰しながら回転させます。右上のバッジは同じタイミングでscaleが脈打つように拡大・縮小します。ヘッダーの通知アイコンや未読件数の表示に使います。

transform-box: fill-boxrotatescale
.bell__body {
  transform-box: fill-box;
  transform-origin: 50% 0%;
}
.bell.is-demo .bell__body { animation: shake $dur-loop $easing infinite; }
.bell.is-demo .badge       { animation: pulse $dur-loop $easing infinite; }

@keyframes shake {
  0%, 55%, 100% { transform: rotate(0deg); }
  60% { transform: rotate(-14deg); }
  65% { transform: rotate(12deg); }
  70% { transform: rotate(-8deg); }
}

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

9つとも壊れやすい場所が違いました。1つ目は07の成功アイコンで、無彩色リングと状態色リング+チェックという2枚のSVGにgrid-area: 1 / 1を付け忘れると、重なるはずの2枚が縦に並んでしまい、バッジ全体の高さが2倍になりました。

2つ目は03の3件スタックです。カードごとに変わるはずの--iをSCSS側に固定値で書いてしまうと、3枚とも同じオフセットを受け取って完全に重なって見えます。要素ごとにstyle="--i:0"のようにインラインで振らないと、デッキが開いて見える形になりません。

3つ目は02のPopover APIトーストです。popover属性の既定UAスタイルは画面中央に固定するもので、positiontoprightmarginをすべて上書きしないと角ではなく中央に浮きます。Popover APIはChrome・Edge 114以降、Safari 17以降、Firefox 125以降で使え(caniuseの対応表)、それより古いブラウザではpopover属性自体が無視されて普通の<div>のまま残ります。08で使ったenv(safe-area-inset-bottom)も同様にブラウザ依存で、iOS Safari 11.2以降でしか実際のノッチ値を返しません(caniuseのenv()対応表)。対応しないブラウザではcalc()のフォールバック値0pxが黙って適用されるだけなので、そちらは安全側に倒れます。

4つ目は06のスワイプで閉じるです。pointerdownsetPointerCaptureを呼んでいないと、指がカードの外に出た瞬間pointermoveが届かなくなり、ドラッグがそこで止まってしまいました。ここまで直した9本のファイルを開くパスワードはvp67sjt7で、下のカードから取り出せます。

アクセシビリティ

9つとも実装コードにrole="status" aria-live="polite"を持たせています。読み上げソフトには伝えつつ、今の作業を止めさせない強さだからです。prefers-reduced-motion: reduceでは01・02・05・06・08はtransform: noneopacity: 1に固定して登場・退場のアニメーションだけを止め、03は-22pxopacity: 1という開いた状態のままtransitionを切ります。07は両レイヤーのアニメーションを止め、状態色レイヤーだけを最終状態に固定し、09はベルの回転とバッジの拡大縮小をどちらも止めます。04だけは進行バーのアニメーションを残し、トーストが震える動きだけを取り除きます。進行バーは「いつ閉じるか」を伝える機能的な情報で、装飾ではないからです。5秒という自動で閉じるまでの時間はWCAG 2.2.1 Timing Adjustableが延長・解除の手段を求めており、05の「元に戻す」ボタンがそのままタイマーを止める役割を兼ねています。

018のマイクロインタラクション9選にあるトーストの積み重ねは新着が来るたびに既存の分を押し上げるキューで、ここ03の3件スタックは常に3枚が固定されたデッキがhoverで開く点が逆方向です。003のモーダルアニメーション9選にあるボトムシートはハンドル付きの大きなパネルですが、08のモバイル下部トーストはハンドルのない一行のピル型で自動的に閉じます。ほかのCSSアニメーションはカテゴリー一覧に、このサイトの狙いはサイト紹介にまとめてあります。

FAQ

role="status"とrole="alert"、どちらを使うべきですか?

ほとんどのトーストはrole="status" aria-live="polite"が適切です。role="alert"aria-live="assertive"は読み上げを即座に割り込ませる強さなので、決済失敗やセッション切れのように必ず今知らせたい通知だけに使います。

自動で閉じるまで何秒が目安ですか?

決まった正解はありませんが、WCAG 2.2.1は時間制限を延長・解除できる手段を求めています。04は5秒を基準にしつつ、05のように「元に戻す」ボタンでタイマーを止められる仕組みを添えて、読み終わる前に消える事態を避けています。

Popover APIに対応していないブラウザではどうなりますか?

popover属性が使えない環境では要素はただの<div>のまま残るので、'showPopover' in document.createElement('div')で対応状況を確認し、非対応のときだけクラスの付け外しで開閉するJSを別途用意します。

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

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