GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

マイクロインタラクション UIパターン9選 — トーストからスクロールスパイまで

マイクロインタラクションは、9つのうち1つは通知が積まれた順番の値から消える時点を求め直し後ろの通知の位置を詰め、1つはスクロールした位置で各見出しの重なり比率の値を比べて活性項目を変え、もう1つは入力の値が変わるたびに待機時間の値を最初から数え直します。

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

9つとも実際の通知サービスや検索API、クリップボードには一切つながっていません。表示されるメッセージも検索結果も保存される値も、すべてデモのスクリプト内だけで完結し、再読み込みで元に戻ります。それでも9つとも本物の入力に反応します。01(トースト通知キュー)と09(クリップボードコピー)は本物のクリック、02(ダイアログのフォーカストラップ)と03(キーボードタブ)はクリックとキー入力の両方、04(スクロールスパイ目次)と05(インターセクションリビール)はIntersectionObserverの本物のコールバックとデモ側のクリックの組み合わせ、06(ショートカット)はキー入力のみ、07(デバウンス検索)は本物の入力イベント、08(テーマ保存)はクリックとウィンドウのリサイズにそれぞれ応答します。

9つの計算層 — どの値がいつ求め直されるか

9つは、クリック・スクロール位置・経過時間という入力を、ブラウザが実際に扱える値へ変換する方法の違いで3グループに分かれます。この計算層を省くと、UIパターンは反応する仕組みではなく単なる装飾に見えてしまいます。マイクロインタラクション 事例の多くは動きだけを見せますが、この9つは動きのもとになる値まで見せています。

経過時間だけで求め直す3つ — 状態の切り替わりを待たない

01・07・09は経過時間そのものを値に変換します。01のdismissAfterはスタック内の順番のインデックスにSTACK_STEP_MSを掛けてBASE_MSに足した値で、後から出たトーストが必ず前のトーストより長く残ります。07はキー入力のたびにrequestAnimationFrameループを再起動し、経過時間をWAIT_MSで割った進行比率の値を求め直します。09はコピー範囲が選ばれた瞬間からリセットまでの時間を求め直すので、ラベルは固定表示のままにならず必ず元の文言に戻ります。

真偽値やキー一致の値を待ってから求め直す3つ

02・06・08は何かが切り替わるまで動きません。02はダイアログが開いた状態になってから[data-f]要素を読み直し、そこからタブ移動できる範囲を求め直します。06はキー入力から組み合わせの値を作ってから登録済みの一覧と照合し、個別のショートカットごとに分岐しません。08はトグルのクリックを待ってから、ノブの実測した矩形をもとに移動距離を求め直し、その値をlocalStorageに書き込みます。

複数の候補を測定・比較して値を決める3つ

03・04・05は測定や比較から値を得ます。03は選択が変わるたびに実際にレンダリングされたタブの位置と幅をgetBoundingClientRect()で読み直し、固定レイアウトを前提にしません。04はコールバックのたびにすべての見出しのintersectionRatioを比較し、最も大きい値を持つ項目を活性にします。05は表示比率の値が0.4のしきい値を超えた瞬間だけ一度比較を行い、以後は状態を固定して比較をやり直しません。

パターンごとのコード

01トースト通知キュー — 積まれた順番の値から消える時点を計算する通知リスト

複数のトーストが同時に積まれると、それぞれの消える時点は表示時間とキュー内の順番の値から再計算され、1つが早く消えると残りのトーストの順番の値が詰められます。

トースト通知キュー — 積まれた順番の値から消える時点を計算する通知リスト
新しいタブで開く

01 · トースト通知キュー — 積まれた順番の値から消える時点を計算する通知リスト — 計測 累積面積 19.387% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STACK_STEP_MSMAX_ROWS--i

数字が示すもの

追跡した23フレーム中10フレームで変化し、累積面積19.39%・平均強度34.3にとどまるのは、追加や削除のたびに積まれた行だけが再描画されるためです。STACK_STEP_MSはdismissAfterを求めるためにBASE_MSへ足す順番ごとの定数で、MAX_ROWSは表示できるトーストを3件までに抑えます。--iというカスタムプロパティがあるからこそ、reindex()が削除後に番号を振り直した瞬間、残った各行の位置が即座に更新されます。

  function add() {
    var el = document.createElement('li');
    el.className = 'tq__row';
    el.style.setProperty('--i', String(items.length));
    var text = MESSAGES[seq % MESSAGES.length];
    seq += 1;
    var dot = document.createElement('span');
    dot.className = 'tq__dot';
    var span = document.createElement('span');
    span.className = 'tq__text';
    span.textContent = text;
    el.appendChild(dot);
    el.appendChild(span);
    list.appendChild(el);
    var item = { el: el };
    items.push(item);
    if (items.length > MAX_ROWS) { remove(items[0]); }
    var order = items.indexOf(item);
    var dismissAfter = BASE_MS + order * STACK_STEP_MS;
    item.timer = setTimeout(function () { remove(item); }, dismissAfter);
    el.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); remove(item); });
  }

02ダイアログのフォーカストラップ — 開いた状態で移動範囲の値を内側に閉じ込めるモーダル

モーダルが開くたびに、その中でタブ移動できる要素の一覧が求め直され、タブ移動はその範囲に閉じ込められます。最後の要素を過ぎると値は最初の要素へ循環して戻ります。

ダイアログのフォーカストラップ — 開いた状態で移動範囲の値を内側に閉じ込めるモーダル
新しいタブで開く

02 · ダイアログのフォーカストラップ — 開いた状態で移動範囲の値を内側に閉じ込めるモーダル — 計測 累積面積 20.114% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

[data-f]preventScroll--i

数字が示すもの

追跡した23フレーム中15フレームで変化し、累積面積20.11%・平均強度73.6となるのは、オーバーレイが開くとパネル全体が再描画され、以降のTabキー操作のたびに--iのマーカーが新しい項目へ移るためです。measure()は固定の個数を前提にせず[data-f]要素をすべて読み直し、setIndex()のモジュロ演算(((i % len) + len) % len)によって、特別な分岐を書かなくても最後の項目から最初の項目へフォーカスが循環します。

  // 計算層: 開くたびにタブ移動可能な要素の一覧を求め直して範囲(0..len-1)を決め、
  // その範囲の外に出ると値が戻って循環する(モジュロ演算) — 固定の個数を前提にしない。
  function measure() {
    focusables = Array.prototype.slice.call(dialog.querySelectorAll('[data-f]'));
  }

  function setIndex(i) {
    var len = focusables.length;
    index = ((i % len) + len) % len;
    marker.style.setProperty('--i', String(index));
    focusables[index].focus({ preventScroll: true });
  }

  function open() {
    measure();
    overlay.hidden = false;
    setIndex(0);
  }

03キーボードタブ — 矢印キーで変わった値の分だけ下線位置を求め直すタブ

矢印キーで活性タブが変わるたびに、そのタブの水平位置と幅が求め直され、下線はその値へ移動します。画面外にあるタブは同じ値の分だけ一覧を横にずらします。

キーボードタブ — 矢印キーで変わった値の分だけ下線位置を求め直すタブ
新しいタブで開く

03 · キーボードタブ — 矢印キーで変わった値の分だけ下線位置を求め直すタブ — 計測 累積面積 0.953% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRect--x--w

数字が示すもの

追跡した23フレーム中8フレームだけで変化し、累積面積はわずか0.95%・平均強度55.4にとどまるのは、パネル全体ではなく細い下線1本だけが動くためです。place()は活性タブの実際の矩形をgetBoundingClientRect()で読み取り、そのオフセットを--xに、幅を--wに書き込みます。同じ関数は対象タブの端が表示範囲の外に出たときだけタブ一覧を横スクロールさせます — 画面に収まらない数のタブを扱うマイクロインタラクション サンプルでよく必要になる処理です。

  function place(i) {
    var tab = tabs[i];
    var containerRect = tabsEl.getBoundingClientRect();
    var rect = tab.getBoundingClientRect();
    var x = rect.left - containerRect.left + tabsEl.scrollLeft;
    var w = rect.width;
    underline.style.setProperty('--x', x.toFixed(1) + 'px');
    underline.style.setProperty('--w', w.toFixed(1) + 'px');
    var left = x;
    var right = x + w;
    if (left < tabsEl.scrollLeft) { tabsEl.scrollLeft = left; }
    else if (right > tabsEl.scrollLeft + tabsEl.clientWidth) { tabsEl.scrollLeft = right - tabsEl.clientWidth; }
  }

  function select(i) {
    var len = tabs.length;
    index = ((i % len) + len) % len;
    tabs.forEach(function (t, k) {
      t.setAttribute('aria-selected', k === index ? 'true' : 'false');
      t.setAttribute('tabindex', k === index ? '0' : '-1');
    });
    panel.textContent = TEXTS[index];
    place(index);
  }

04スクロールスパイ目次 — 重なり比率の値を比較して活性項目を変える目次

スクロールされるたびに各見出しの重なり比率が求め直され、最も大きい値を持つ見出しが活性になり、目次のハイライトはその項目の位置値へ移動します。

スクロールスパイ目次 — 重なり比率の値を比較して活性項目を変える目次
新しいタブで開く

04 · スクロールスパイ目次 — 重なり比率の値を比較して活性項目を変える目次 — 計測 累積面積 2.205% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

IntersectionObserverintersectionRatio--i

数字が示すもの

追跡した23フレーム中10フレームで変化し、累積面積2.21%・平均強度136.3という組み合わせは、小さな面積が鋭くコントラストの高い動きをしていることを示します。IntersectionObserverのコールバックは発火のたびに各セクションのintersectionRatioを配列へ保存し、そのあと線形走査で最も大きい比率を持つインデックスを選びます。固定のスクロール位置ではなく、全セクションを比較した値が活性リンクを決めます。

  // 計算層: 毎フレーム、すべての見出しの重なり比率(intersectionRatio)の値を比較し、
  // 最も大きい値を持つ項目を活性に選ぶ — 固定のスクロール位置ではなく値の比較で活性項目が決まる。
  function activate(i) {
    if (i === active) { return; }
    active = i;
    marker.style.setProperty('--i', String(active));
    links.forEach(function (l, k) { l.setAttribute('aria-current', k === active ? 'true' : 'false'); });
  }

  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      var i = Number(entry.target.getAttribute('data-h'));
      ratios[i] = entry.intersectionRatio;
    });
    var best = 0;
    for (var k = 1; k < ratios.length; k++) { if (ratios[k] > ratios[best]) { best = k; } }
    if (ratios[best] > 0) { activate(best); }
  }, { root: pane, threshold: [0, .25, .5, .75, 1] });
  sections.forEach(function (s) { io.observe(s); });

05インターセクションリビール — 表示比率の値が基準を超えた瞬間に一度だけ現れる要素

要素の表示比率の値がしきい値を超えた瞬間に一度だけ表示の計算が走り、そのあと画面外に出ても状態の値はそのまま保たれ、二度と計算はやり直されません。

インターセクションリビール — 表示比率の値が基準を超えた瞬間に一度だけ現れる要素
新しいタブで開く

05 · インターセクションリビール — 表示比率の値が基準を超えた瞬間に一度だけ現れる要素 — 計測 累積面積 21.367% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

IntersectionObserverunobserveintersectionRatio

数字が示すもの

追跡した23フレーム中8フレームで変化し、累積面積21.37%・平均強度92.4となるのは、小さなマーカーではなくカード全体が一度に現れるためです。しきい値の配列([0, .4, .8])は、コールバックが0.4付近の使える比率で発火するためだけに存在します。entry.intersectionRatioがその値に達した瞬間にrevealedがtrueになり、io.unobserve(card)が即座に実行されるので、あとでスクロールを戻しても表示は二度と発火しません。

  // 計算層: 表示比率の値が基準(.4)を超えた瞬間に一度だけ状態を反転し、直ちに観察をやめる。
  // その後比率の値が再び下がっても(スクロールを戻しても)状態の値はそのまま保たれる — 再実行はない。
  var io = new IntersectionObserver(function (entries) {
    var entry = entries[0];
    if (!revealed && entry.intersectionRatio >= 0.4) {
      revealed = true;
      card.classList.add('ir__card--in');
      io.unobserve(card);
    }
  }, { root: pane, threshold: [0, .4, .8] });
  io.observe(card);

06キーボードショートカット — 入力の組み合わせの値を判定して動作を探す案内

キーの組み合わせが入力されるたびにその組み合わせの値が求め直され、登録済みの一覧と照合されます。一致するとアクションが実行され、対応するヒントが画面上で一瞬光ります。

キーボードショートカット — 入力の組み合わせの値を判定して動作を探す案内
新しいタブで開く

06 · キーボードショートカット — 入力の組み合わせの値を判定して動作を探す案内 — 計測 累積面積 20.504% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ctrlKeydata-comboaria-live

数字が示すもの

追跡した23フレーム中12フレームで変化し、累積面積20.50%・平均強度82.0となるのは、ヒントの行全体が光ると同時に結果表示も更新されるためです。combo()はキー入力を1つの文字列('ctrl+'と小文字化したキー)にまとめ、trigger()はその文字列をACTIONSのマップに直接照合します。個別のショートカットごとに分岐を増やさなくても、同じ照合がctrl+s・ctrl+f・ctrl+dの3つすべてを扱います。

  // 計算層: 押されたキーから組み合わせの値を求め直し(修飾キー+文字キー)、登録済みの一覧と照合する。
  // 各ショートカットを個別に処理せず、1つの値比較関数が3つとも判定する。
  function combo(e) {
    var mod = e.ctrlKey || e.metaKey ? 'ctrl+' : '';
    return mod + String(e.key).toLowerCase();
  }

  function trigger(key) {
    var hit = hints.filter(function (h) { return h.getAttribute('data-combo') === key; })[0];
    if (!hit) { return; }
    hints.forEach(function (h) { h.classList.remove('ks__hint--hit'); });
    clearTimeout(flashTimer);
    clearTimeout(resetTimer);
    void hit.offsetWidth;
    hit.classList.add('ks__hint--hit');
    result.textContent = ACTIONS[key];
    resetTimer = setTimeout(function () {
      hit.classList.remove('ks__hint--hit');
      result.textContent = '待機中';
    }, 900);
  }

07デバウンス検索 — 最後の入力の値から待機時間が経つと実行される検索

入力の値が変わるたびに待機時間の値は最初から数え直され、その値が途中で止まらずに最後まで数え終わったときだけ検索が実行されます。

デバウンス検索 — 最後の入力の値から待機時間が経つと実行される検索
新しいタブで開く

07 · デバウンス検索 — 最後の入力の値から待機時間が経つと実行される検索 — 計測 累積面積 20.589% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WAIT_MSrequestAnimationFrame--r

数字が示すもの

追跡した23フレーム中19フレームで変化し、9つの中で最多となる累積面積20.59%・平均強度137.1を記録するのは、数えている間ずっと進行バーが毎フレーム再描画されるためです。onInput()はキー入力のたびに保留中のrafとtimerを破棄して両方を最初から再起動し、tick()は毎フレーム経過時間をWAIT_MSで割った比率で--rを求め直します。バーの幅は固定時間のCSSトランジションではなく、実測値そのものです。

  var WAIT_MS = 480;
  var ITEMS = ['アップルパイのレシピ', 'リンゴの木の病害虫', 'アップルジュースの作り方', 'バナナスムージー', 'バナナブレッドを焼く'];
  var timer = null;
  var startedAt = 0;
  var raf = null;
  var autoplay = null;

  // 計算層: 入力の値が変わるたびに残りの待機時間を最初から数え直す(デバウンス)。
  // 進行バーの幅は実際の経過時間 / WAIT_MS の比率から毎フレーム求め直す — 固定トランジションではない。
  function tick() {
    var elapsed = Date.now() - startedAt;
    var ratio = Math.min(1, elapsed / WAIT_MS);
    bar.style.setProperty('--r', ratio.toFixed(3));
    if (ratio < 1) { raf = requestAnimationFrame(tick); }
  }

08テーマ保存 — 保存された値を読んで初期テーマを決めるトグル

テーマが切り替わるたびにその値が保存され、ページを開き直すと保存された値が読み込まれ、描画が始まる前に初期テーマがその値に設定されます。

テーマ保存 — 保存された値を読んで初期テーマを決めるトグル
新しいタブで開く

08 · テーマ保存 — 保存された値を読んで初期テーマを決めるトグル — 計測 累積面積 6.717% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

localStoragegetBoundingClientRect--x

数字が示すもの

追跡した23フレーム中12フレームで変化し、累積面積6.72%・平均強度84.6にとどまるのは、ステージ全体ではなくノブと小さなプレビューカードだけが再描画されるためです。place()はトラックとノブの矩形をgetBoundingClientRect()で測定して移動距離を求めるので、固定の割合を書かず、トラック幅が変わっても正しい位置に収まります。値が変わるたびにdemo-348-という接頭辞つきのキーへ書き込まれるので、再読み込みの直後、最初の描画より前にその値を読み戻せます。

  // 計算層: トラックとノブの実測した矩形から移動距離を求め直し、その値だけ --x で動かす(固定%ではない)。
  // 値が変わるたびに localStorage に書き込み、次回の読み込みで同じ値を復元できるようにする。
  function place() {
    var trackRect = track.getBoundingClientRect();
    var knobRect = knob.getBoundingClientRect();
    var travel = trackRect.width - knobRect.width - 6;
    knob.style.setProperty('--x', (dark ? travel : 0).toFixed(1) + 'px');
  }

  function apply(next, persist) {
    dark = next;
    track.setAttribute('aria-pressed', String(dark));
    card.setAttribute('data-theme', dark ? 'dark' : 'light');
    valueEl.textContent = dark ? 'ダーク' : 'ライト';
    place();
    if (persist) {
      try { window.localStorage.setItem(KEY, dark ? 'dark' : 'light'); } catch (err) { /* 無視 */ }
    }
  }

  try {
    var saved = window.localStorage.getItem(KEY);
    if (saved === 'dark') { apply(true, false); } else { apply(false, false); }
  } catch (err) { apply(false, false); }

09クリップボードコピー — 成功状態の値でラベルが一時的に変わる基本ボタン

範囲を選び終えると、その計算がラベルを完了状態へ切り替え、一定時間後に値をリセットしてラベルを元の文言へ戻します。

クリップボードコピー — 成功状態の値でラベルが一時的に変わる基本ボタン
新しいタブで開く

09 · クリップボードコピー — 成功状態の値でラベルが一時的に変わる基本ボタン — 計測 累積面積 21.3% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--top--hdata-n

数字が示すもの

追跡した23フレーム中8フレームで変化し、累積面積21.30%・平均強度73.7となるのは、選ばれた行の背後を幅広いハイライト帯が動くためです。range()はクリックされた2つの行番号をLINE_Hから{ top, height }の組へ変換し、select()はその値を--topと--hに書き込むことで、固定の状態間をアニメーションさせるのではなくsweep要素そのものを位置決めします。実際のnavigator.clipboardは一度も呼び出されず、訪問者が見る「成功」はラベルの切り替えとその後のタイマーによる元の文言への復帰だけで表現されています。

  // 計算層: 開始行と終了行の値の差から範囲の高さ・位置を求め直し、その位置にsweepを置く。
  // 実際のクリップボードAPIは呼び出さず、コピー成功の状態だけをラベルの切り替えで再現する。
  function range(a, b) {
    var lo = Math.min(a, b);
    var hi = Math.max(a, b);
    return { top: (lo - 1) * LINE_H, height: (hi - lo + 1) * LINE_H, lo: lo, hi: hi };
  }

  function select(startN, endN) {
    var r = range(startN, endN);
    sweep.style.setProperty('--top', r.top + 'px');
    sweep.style.setProperty('--h', r.height + 'px');
    sweep.classList.add('cc__sweep--on');
    nums.forEach(function (btn) {
      var n = Number(btn.getAttribute('data-n'));
      btn.classList.toggle('cc__num--sel', n >= r.lo && n <= r.hi);
    });
    label.textContent = r.lo + '-' + r.hi + '行コピー済み';
    label.classList.add('cc__label--done');
    clearTimeout(resetTimer);
    resetTimer = setTimeout(function () {
      label.textContent = '範囲を選択するとコピーされます';
      label.classList.remove('cc__label--done');
    }, 900);
  }

値がパターンを作り変える場所 — 順番の値・比率の値・待機時間の値

パターンの中心となる値が何を表すかを変えるだけで、同じマークアップの下でもインタラクション全体の印象は変わります。

トースト通知キューの順番の値が新着行だけでなくスタック全体を同期させる必要がある理由

01の順番の値は一度書かれて終わりではありません。reindex()は1つが削除された瞬間に残っているトーストすべての--iを書き直します。新着トーストの位置だけを求め直す実装なら、削除されたトーストがあった場所に見た目の隙間が残ってしまいます。順番の値をリストごとの共有状態として扱う設計こそが、スタックを途切れなく見せる理由です。

スクロールスパイ目次の比較する比率が1つの見出しだけでなく全見出しの観察を必要とする理由

04はすべてのセクションを同時に観察し続ける必要があります。「一番上に近い見出し」という単一の測定だけで活性項目を決める設計は、2つの見出しが折り返し付近で近接した瞬間に破綻します。すべてのintersectionRatioの値をコールバックのたびに比較するからこそ、複数が同時に部分的に見えていても最大の値が勝てます。

デバウンスの待機時間の値が延長ではなく再起動でなければならない理由

07のWAIT_MSのカウントダウンは、キー入力のたびに延長されるのではなくゼロから再起動されます。この違いは重要です。延長だけの実装では、合計の入力時間がしきい値を超えた瞬間に単語の途中で検索が発火してしまいますが、完全な再起動なら実際に入力が止まった後にだけ検索が走ることを保証できます。

壊れる場所 — 連続入力・フォーカス移動・非力な端末

9 Copy-to-Clipboard Button UI Patterns・9 Breadcrumb UI Patterns・Keyboard Shortcuts UI Designとこの9つが違うのは、それぞれが求め直す値そのものです。コピーボタンの回はボタン1つ分の成功状態の値を9種類のラベルで求め直します。パンくずの回はセグメントが折りたたまれるたびにパスそのものの深さと省略幅を求め直します。ショートカットの回は1つのキー組み合わせの値を登録済み一覧に照合するだけです。この9つは、スタックの順番・フォーカスの範囲・測定したタブ位置・比較した比率の組・待機時間のカウントダウン・保存されたテーマの値・コピー範囲の高さという、まったく別の値を求め直しています。この9つの完全な実装は、この記事のzipの中でntw2bmzvの先に置かれています。

連続で速い入力がデバウンスのタイマーとトーストのキューに与えるもの

07のタイマー再起動そのものは軽い処理ですが、タイピングの速い人がキーを打つたびに発火すると、待機時間の値が一度も数え終わらないうちにcancelAnimationFrameとclearTimeoutが何十回も走ります。ここでは無害ですが、実運用では再起動の途中で空のクエリのままsearch()が呼ばれないようにする保護をどこかに足すべきです。01のスタックは逆の問題を抱えています。MAX_ROWSが表示できるトーストを3件に抑えているので、連続した発火は自分のタイマーが鳴る前に古いトーストをremove()で押し出してしまいます — これは意図した挙動ですが、実際の通知基盤に組み込む人には明記しておく価値があります。

ダイアログのフォーカストラップとキーボードタブが同じページに同居するときフォーカス移動に起きること

02のフォーカストラップと03のタブ一覧は、どちらもプログラムからフォーカスを動かします。同じページに両方を置くなら、タブパネルの中から開いたモーダルは、閉じるときにそのタブへフォーカスを戻す必要があり、ブラウザ既定のフォーカス順に任せるだけでは足りません。closeBtnのハンドラーがすでにopenBtnへフォーカスを戻しているのはこの理由からで、タブから開くダイアログでも同じ設計にすべきです。

非力な端末が1ページ上の9つの独立したリスナーに与えるもの

この9つのどれもwindow.scrollへ直接リスナーを付けていません。04と05はブラウザのコンポジターが発火のタイミングを決められるよう、あえてIntersectionObserverを使い、スクロールフレームごとにハンドラーを走らせません。それでも非力な端末で9つのデモを同時に1ページへ乗せれば、フレームの予算は9等分されます。実際のページでは9つをギャラリーとして並べるのではなく、実際に使うパターンだけを組み込むべきです。

3つのバリエーションとアクセシビリティ

下の数字は出発点として扱ってください。プレースホルダーのメッセージ・キー組み合わせ・保存される値をすべて実際のコンテンツに差し替えた後も、求め直す仕組み自体は変わらず、定数だけが変わります。

バリエーション 変わる箇所 トレードオフ
デバウンス検索の待機時間 項目07のWAIT_MSを480msから900msへ 480msでは結果がほぼ即時に感じられますが、入力し続ける人は単語1つの間に何度も検索を発火させます。900msでは入力中の発火は減りますが、最後のキー入力からの遅延がより目立ちます
トーストの積み重ね間隔 項目01のSTACK_STEP_MSを900msから1500msへ 900msでは連続したトーストがすぐに消化されます。1500msでは同時に画面へ残るトーストが増えて賑やかに見えますが、訪問者が1つずつ読む時間は増えます
タブ下線の移動時間 項目03の$durationを$dur-enter(600ms)から$dur-loop(2s)へ 600msなら素早く連続でタブを押しても下線が追いつきます。2sでは素早い切り替えに下線が明らかに遅れますが、1回だけのゆっくりした切り替えはより意図的に見えます

このzipが出荷する値は480ms・900ms・600msです。

動きの低減設定やキーボード・スクリーンリーダーの利用者も同じ計算値にたどり着く理由

9つともprefers-reduced-motionを確認し、その設定下では自動再生のループだけを止め、本物のリスナーは残します。動きの低減を有効にした訪問者がトーストのボタンを押しても、ダイアログを開いても、検索欄に入力しても、求め直された値そのものはデモの自動再生プレビューがないだけで変わりません。キーボード対応は後付けではなく作りに組み込まれています。02のダイアログはTabとShift+Tabを自分のフォーカス範囲の中に閉じ込め、Escapeで閉じます。03のタブはrole="tab"とaria-selected、ロービングなtabindexを使い、矢印キーでの選択移動がページ全体のタブ順に余計な停止点を増やしません。06のショートカットは、目で見える訪問者が確認する要素と同じ場所にaria-live="polite"で結果を読み上げます。01のトースト一覧もaria-live="polite"なので、スクリーンリーダーはスタックが番号を振り直すたびにではなく、新しいメッセージごとに一度だけ読み上げます。

FAQ

この9つのうち、本物の通知・検索・クリップボードサービスを呼び出しているものはありますか

いいえ。トーストのメッセージも、デバウンス検索の結果も、クリップボードコピーの「成功」状態も、すべてローカルで計算されて表示されているだけです。この9つのどこにもnavigator.clipboardや本物の検索API、プッシュ通知サービスへの呼び出しはありません。

これらはマイクロインタラクション アニメーションの見本にすぎないのですか、それともそのまま使えますか

見本として眺めるだけのものではなく、そのまま使えるように作られています。9つとも上に示した計算部分は25行以内で、外部ライブラリを一切使っていません。

9つのうち、値を延長するのではなく最初から求め直しているものはどれですか

3つあります。07は入力のたびに待機時間のカウントダウンを延長せずゼロから求め直します。01は1つが削除されるたびに残ったトーストの順番の値をすべて最初から求め直します。08はトグルのたびにキャッシュした数値を使い回さず、ノブの移動距離を新しい測定から求め直します。

Sources: MDN — IntersectionObserver · MDN — Window.localStorage · MDN — ARIA tab role · MDN — Clipboard API

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

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