UI/UX
投稿日:

在庫・空き枠の数字が変わった瞬間だけ光らせる ─ 安心感を作るカウントアップ演出

こんにちは、株式会社ファストコーディングのBigViです。最近、夫と一緒にホテルの予約サイトを見ていました。「残り2部屋」って表示があって、ちょっと焦りました。。でもページを更新したら「残り3部屋」に増えていて、「あれ?キャンセル出た?」って混乱しました。数字が変わったのに、何も演出がないから気づきにくかったです。

先日、お客さまから「予約ページの空き枠の数字を表示しているけど、リアルタイムで変わるのに気づいてもらえない」と相談されました。空き枠が減ったときに焦らせたくないけど、変わったことは伝えたい。難しいバランスです。

私もよくあります。ECサイトで「残り5点」って見て、しばらくして見たら数字が変わっていたのに気づかないこと。。

今回は「数字が変わった瞬間だけ、数値にフェード+カウントアップ風の演出をつける」という実装をしました。IntersectionObserverとMutationObserverを組み合わせて、画面に見えているときだけ反応します。

なぜ「変わった瞬間」だけなのか

在庫や空き枠の数字は、ページを開いたときに見えるだけでは足りないです。リアルタイムで変動するサイトでは、ユーザーが見ている間に数字が変わることがあります。

でも、ずっとアニメーションしていたらうるさいです。最初から点滅していたら「何が変わったの?」ってわかりません。大事なのは「さっきと違う」という変化の瞬間を伝えることです。

ある案件で、常にパルスアニメーションをつけたことがあります。クライアントに見せたら「ずっと動いていて、広告みたいで嫌だ」と言われました。。変わった瞬間だけ動くようにしたら、「これなら安心感がある」と言ってくれました。

実装方法

HTML構造

HTMLはシンプルです。数字を表示する要素に、data-stock属性をつけます。

<div class="stock-display" id="js-stock">
  <span class="stock-label">空き枠</span>
  <span class="stock-number" id="js-stock-number" data-stock="5">5</span>
  <span class="stock-unit">件</span>
</div>

<div class="stock-display" id="js-stock-room">
  <span class="stock-label">残り</span>
  <span class="stock-number" id="js-stock-room-number" data-stock="3">3</span>
  <span class="stock-unit">部屋</span>
</div>

CSSスタイル

アニメーションは2つあります。1つは背景のフェード、もう1つは数字が少し大きくなる動きです。

.stock-display {
  display: inline-flex;
  align-items: baseline;
  gap: 4px;
  font-size: 14px;
  color: #333;
}

.stock-number {
  font-size: 28px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  min-width: 2ch;
  text-align: center;
  transition: background-color 0.3s ease;
  border-radius: 4px;
  padding: 2px 6px;
}

.stock-number.is-updating {
  animation: stock-flash 0.6s ease-out;
}

@keyframes stock-flash {
  0% {
    background-color: rgba(34, 197, 94, 0.2);
    transform: scale(1.15);
  }
  100% {
    background-color: transparent;
    transform: scale(1);
  }
}

@media (prefers-reduced-motion: reduce) {
  .stock-number.is-updating {
    animation: none;
    background-color: rgba(34, 197, 94, 0.15);
    transition: background-color 0.8s ease-out;
  }
}

font-variant-numeric: tabular-numsは、数字の幅を揃えるためのプロパティです。数字が変わったときにレイアウトがガタつかないようにしています。

JavaScriptの実装

IntersectionObserver(要素が画面に見えているかを検知するAPI)とMutationObserver(DOMの変更を検知するAPI)を組み合わせます。

document.addEventListener('DOMContentLoaded', () => {
  const stockNumbers = document.querySelectorAll('.stock-number');
  const visibleSet = new Set();

  // 画面に見えているかを監視
  const io = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        visibleSet.add(entry.target);
      } else {
        visibleSet.delete(entry.target);
      }
    });
  }, { threshold: 0.5 });

  stockNumbers.forEach((el) => io.observe(el));

  // data-stock属性の変更を監視
  const mo = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      if (mutation.attributeName !== 'data-stock') return;
      const el = mutation.target;

      // 画面に見えていないなら数字だけ更新
      if (!visibleSet.has(el)) {
        el.textContent = el.dataset.stock;
        return;
      }

      // カウントアップ演出
      const oldVal = parseInt(el.textContent, 10);
      const newVal = parseInt(el.dataset.stock, 10);
      if (oldVal === newVal) return;

      animateCount(el, oldVal, newVal);
    });
  });

  stockNumbers.forEach((el) => {
    mo.observe(el, { attributes: true, attributeFilter: ['data-stock'] });
  });

  function animateCount(el, from, to) {
    const duration = 400;
    const start = performance.now();
    const diff = to - from;

    el.classList.add('is-updating');

    function step(now) {
      const progress = Math.min((now - start) / duration, 1);
      const eased = 1 - Math.pow(1 - progress, 3);
      const current = Math.round(from + diff * eased);
      el.textContent = current;

      if (progress < 1) {
        requestAnimationFrame(step);
      } else {
        el.textContent = to;
        setTimeout(() => el.classList.remove('is-updating'), 200);
      }
    }

    requestAnimationFrame(step);
  }
});

ポイントはvisibleSetです。IntersectionObserverで画面に見えている要素だけをSetに入れておいて、MutationObserverでdata-stockが変わったときに「見えているか?」をチェックします。見えていなければ静かに更新するだけです。

カウントアップはrequestAnimationFrameを使っています。400msかけて、古い数字から新しい数字までイージングで変化します。60fpsで滑らかに動きます。

注意点

実装するときに気をつけたことが3つあります。

1つ目はprefers-reduced-motionです。アニメーションが苦手なユーザーのために、CSSで動きを無効にしています。代わりに背景色だけがゆっくり変わるようにしました。

2つ目はパフォーマンスです。MutationObserverはattributeFilterdata-stockだけを監視しています。他の属性の変更には反応しません。IntersectionObserverのthresholdは0.5にして、半分以上見えているときだけ反応します。

3つ目はfont-variant-numeric: tabular-numsです。数字の幅が変わるフォント(プロポーショナル数字)だと、1と8で幅が違って、カウントアップ中にガタガタ動きます。tabular-numsで等幅にすると、安定して動きます。

実際の結果

このお客さまのプロジェクトで、A/Bテストを4週間やりました。

指標演出なし(A)演出あり(B)差分
空き枠変動の認知率31.2%54.8%+23.6pt
予約完了率12.4%14.9%+2.5pt
ページ平均滞在時間48秒55秒+7秒

空き枠変動の認知率が23.6ポイント上がりました。「空き枠が変わったことに気づきましたか?」というアンケートの結果です。予約完了率も2.5ポイント上がりました。焦らせない演出でも、変化に気づいてもらえると行動につながるようです。ただし、これは1つのプロジェクトでの結果です。効果は表示する商品の種類やユーザー層によって変わります。

まとめ

在庫・空き枠の数字は、変わったことに気づいてもらえないと意味がないです。でも常に動いていたら広告みたいで逆効果。「変わった瞬間だけ」がポイントです。

今回のポイントは以下の3つです:

  • IntersectionObserverで「見えているとき」だけ演出する。画面外では静かに更新
  • MutationObserverでdata-stock属性の変更を検知。ポーリングより効率的
  • カウントアップは400ms、背景フェードは0.6秒。prefers-reduced-motionに対応

お客さまからは「ユーザーが焦らずに、でも空きが減ったことにちゃんと気づいてくれるようになった」と喜んでくれました。。

株式会社ファストコーディングでは、こうしたリアルタイム表示のUI演出やフロントエンドの実装サポートをしています。「在庫表示を改善したい」「予約ページのCVRを上げたい」という方は、お問い合わせフォームから気軽にご連絡ください。


※本記事は弊社外国人スタッフによる投稿です。言い回しや表現が不十分な個所がありますことご容赦いただきますようお願いいたします。