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

オートコンプリート 実装 — ハイライト・デバウンス・編集距離を重ねた9種

検索窓のオートコンプリート 実装は文字を打つたびに再計算が走る一枚の層です。入力した文字が候補のどこにあるかを見つけてその部分だけ太字にし、300msのデバウンスでリクエストをまとめ、キーボードでもリストを巡れます。最近の検索・誤字候補・人気順位バーまで — オートコンプリート9種、コードはパスワードzipで。

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

デモ

9項目とも入力欄・候補リスト・選択状態という3層構造を共有していますが、その上に乗る計算(部分一致ハイライト・デバウンスチケット・レーベンシュタイン編集距離・mod循環・比率スケール)は項目ごとに違います。実測値は次の表のとおりです。

番号 項目 累積面積 強度平均 動いたフレーム
01 一致ハイライト 15.731% 177.2 6/23
02 デバウンスシマー 22.247% 33.3 22/23
03 キーボード循環 35.733% 94.6 23/23
04 最近の検索チップ 7.149% 59.8 7/23
05 グループ固定ヘッダー 34.696% 59.6 23/23
06 誤字候補 45.977% 89.8 7/23
07 急上昇バー 4.804% 184.9 12/23
08 モバイルシート変身 29.61% 66.5 13/23
09 リデュースモーション 30.961% 47.1 20/23

06の誤字候補が累積面積45.977%で最も広く、07の急上昇バーが4.804%で最も狭いのは、07がバー1本ずつの狭い領域だけを塗り替えるのに対し06はリストの行そのものが入れ替わるからです。01・04・06が24フレームのうち6〜7フレームしか動かないのは欠陥ではなく、自動演示のタイピング順序に入力を止めて次の文字を待つ静止区間が実際に入っているためで、03・05・09のようにスクロールや常時アニメーションを持つ項目は23フレームまで動きます。強度平均は02のデバウンスシマーが33.3で最も落ち着き、07が184.9で最も明るく、9項目とも下限(累積面積0.08%以上・強度15以上・動いたフレーム6以上)に十分な余裕をもって収まります。

オートコンプリートの解剖 — 入力・リスト・選択の3層とaria-combobox構造

html オートコンプリート 実装を検索して来た人が最初に確認すべきは、見た目より先に構造です。入力欄・候補リスト・選択状態という3つの役割を、外側のroot要素にrole="combobox"・aria-expanded・aria-haspopup="listbox"・aria-ownsを置き、内側のinputにrole="searchbox"・aria-autocomplete="list"・aria-controlsを載せる形に固定します。

role="combobox"を外側の要素に置く理由

外側のdivにrole="combobox"を持たせるのは、開閉状態(aria-expanded)と所有するリストのid(aria-owns)を一箇所にまとめて、スクリーンリーダーが「これは今何を指しているか」を迷わず読めるようにするためです。

inputはrole="searchbox"のまま残す

ネイティブのinputをカスタム要素に置き換えると、矢印キー・Tabフォーカス・IME変換のすべてを自分で再実装する必要が生まれます。role="searchbox"・aria-autocomplete="list"・aria-controlsだけを載せ、input自体は消しません。

ul[role="listbox"] > li[role="option"]のリスト

候補リストはul[role="listbox"]の中にli[role="option"]を並べる形に固定し、divの入れ子やbuttonへの置き換えはしません。この形でなければ、スクリーンリーダーが候補の総数と現在位置を読み上げられません。

aria-activedescendantで選択位置を伝える

矢印キーで移動したアクティブな項目のidをinputのaria-activedescendantに毎回書き込むと、フォーカス自体はinputに残したまま、スクリーンリーダーだけに「今どの行を指しているか」を伝えられます。この3層構造は独自の思いつきではなく、ARIAオーサリングプラクティスのCombobox パターン — W3Cが定める役割分担そのものです。

9つの検索窓の段階別コード

オートコンプリート 実装方法は9項目とも3層構造の骨格を共有しますが、その上に乗る計算(部分一致ハイライト・デバウンスチケット・レーベンシュタイン編集距離・mod循環・比率スケール)は項目ごとに違います。自動演示はperformance.now()の時計かindex * STEP_MSのずらし遅延で決まった順序だけを再生するため、キャプチャごとに同じ場面になります。

01一致ハイライト — 入力文字が候補のどこにあるか探し太字にする計算

候補文字列でindexOf(query, i)を繰り返し呼んで見つかった位置ごとにで囲むと、同じ単語の中に同じ文字が2回現れても両方とも強調されます。比較は両方toLowerCaseで統一するため大文字小文字は無視され、累積面積15.731%・面積最大15.635%・強度平均177.2・動いたフレーム6/23です。

一致ハイライト — 入力文字が候補のどこにあるか探し太字にする計算
新しいタブで開く

01 · 一致ハイライト — 入力文字が候補のどこにあるか探し太字にする計算 — 計測 累積面積 15.731% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

beforeinputCSS custom propertiesaria-expanded

indexOfを繰り返してすべての一致を集めるループ

自動演示は{q, ms}の順序をperformance.now()で再生し、空文字に戻る区間だけ画面が静止します(6/23はこの静止による自然な値です)。

  function highlightAll(text, query) {
    if (!query) return escapeHtml(text);
    var lowerText = text.toLowerCase();
    var lowerQuery = query.toLowerCase();
    var out = '', i = 0;
    while (i < text.length) {
      var idx = lowerText.indexOf(lowerQuery, i);
      if (idx === -1) { out += escapeHtml(text.slice(i)); break; }
      out += escapeHtml(text.slice(i, idx));
      out += '<mark class="hl__mark">' + escapeHtml(text.slice(idx, idx + query.length)) + '</mark>';
      i = idx + query.length;
    }
    return out;
  }

02デバウンスシマー — 300msで入力をまとめ、待ち時間にリストが煌めく状態

毎入力でタイマーをリセットするデバウンス(DEBOUNCE_MS)に加え、発火ごとに増えるpendingIdチケットを刻んで応答が届いた時に最新かどうかを比較します。待機中は左右に流れるシマー行を見せ、累積面積22.247%・面積最大22.22%・強度平均33.3・動いたフレーム22/23です。

デバウンスシマー — 300msで入力をまとめ、待ち時間にリストが煌めく状態
新しいタブで開く

02 · デバウンスシマー — 300msで入力をまとめ、待ち時間にリストが煌めく状態 — 計測 累積面積 22.247% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

setTimeoutrequestAnimationFrametransition

pendingIdを比較して古い応答を捨てるsetTimeoutの入れ子

デバウンスタイマーが切れても即確定させず、架空の通信待ち(FAKE_LATENCY_MS)をもう一段挟んでからmyIdとpendingIdを比較するので、遅れて届いた古い応答は画面に触れずに捨てられます。

  function onChange(value) {
    clearTimeout(debounceTimer);
    pendingId++;
    var myId = pendingId;
    if (!value) {
      state = 'idle';
      renderIdle();
      return;
    }
    state = 'loading';
    renderSkeleton();
    debounceTimer = setTimeout(function () {
      setTimeout(function () {
        if (myId !== pendingId) return;
        var q = value.toLowerCase();
        results = WORDS.filter(function (w) { return w.toLowerCase().indexOf(q) !== -1; });
        state = 'done';
        renderResults(value);
      }, FAKE_LATENCY_MS);
    }, DEBOUNCE_MS);
  }

03キーボード循環 — ↑↓で項目を選び、端から反対側へ戻るmod計算

activeIndex = (activeIndex + delta + WORDS.length) % WORDS.lengthで循環させ、最後の項目で↓を押すと最初の項目へ戻ります。同じフレームでaria-activedescendantを更新しscrollIntoView({block:'nearest'})を呼び、累積面積35.733%・面積最大15.111%・強度平均94.6・動いたフレーム23/23です。

キーボード循環 — ↑↓で項目を選び、端から反対側へ戻るmod計算
新しいタブで開く

03 · キーボード循環 — ↑↓で項目を選び、端から反対側へ戻るmod計算 — 計測 累積面積 35.733% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

keydownaria-activedescendantscrollIntoView

aria-activedescendantの更新とscrollIntoViewを1つの関数にまとめる

自動演示はSTEP_MSごとに1つ進むtickをrequestAnimationFrameの中で回し、keydown・pointerdownが来た瞬間にstop()で自動演示を止めます。

  function updateActive() {
    for (var i = 0; i < items.length; i++) items[i].classList.remove('is-active');
    var el = items[activeIndex];
    if (!el) return;
    el.classList.add('is-active');
    input.setAttribute('aria-activedescendant', el.id);
    el.scrollIntoView({ block: 'nearest' });
  }

  function moveActive(delta) {
    activeIndex = (activeIndex + delta + WORDS.length) % WORDS.length;
    updateActive();
  }

04最近の検索チップ — 保存時刻を再計算し「3分前・2日前」ラベルを書き直すチップ

検索語とタイムスタンプをlocalStorageに積み、開くたびにnow - tsをMIN・HOUR・DAYの定数で割って「たった今・n分前・n時間前・n日前」ラベルを作り直します。累積面積7.149%・面積最大5.846%・強度平均59.8・動いたフレーム7/23です。

最近の検索チップ — 保存時刻を再計算し「3分前・2日前」ラベルを書き直すチップ
新しいタブで開く

04 · 最近の検索チップ — 保存時刻を再計算し「3分前・2日前」ラベルを書き直すチップ — 計測 累積面積 7.149% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

localStoragetransform: scaleCSS custom properties

現在時刻との差を毎回計算し直すlabel関数

現在時刻とtsの差を毎回計算し直すので、同じチップでもページを開くたびに「n分前」の値が今の時刻に合わせて変わります(7/23はタイマー切り替えの合間の静止による自然な値です)。

  function label(delta) {
    if (delta < MIN) return 'たった今';
    if (delta < HOUR) return Math.floor(delta / MIN) + '分前';
    if (delta < DAY) return Math.floor(delta / HOUR) + '時間前';
    return Math.floor(delta / DAY) + '日前';
  }

05グループ固定ヘッダー — リストをグループに分け、スクロールでヘッダーが張り付く構造

次のヘッダーが現在のヘッダーを押し出す直前の区間をgetBoundingClientRectで毎フレーム測ってoverlapRatio(0〜1)を求め、その値をbox-shadowの不透明度に変換します。累積面積34.696%・面積最大10.126%・強度平均59.6・動いたフレーム23/23です。

グループ固定ヘッダー — リストをグループに分け、スクロールでヘッダーが張り付く構造
新しいタブで開く

05 · グループ固定ヘッダー — リストをグループに分け、スクロールでヘッダーが張り付く構造 — 計測 累積面積 34.696% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

position: stickyscrollCSS custom properties

overlapRatioをbox-shadowの不透明度に変換するapplyShadow

スクロール位置そのものではなく次ヘッダーの相対位置(nextTop)を毎フレーム測るので、リサイズやフォントサイズの変化にも同じ式がそのまま追随します。

  function applyShadow(i, ratio) {
    var head = heads[i];
    if (!head) return;
    var alpha = 0.10 + 0.28 * ratio;
    head.style.boxShadow = '0 6px 10px rgba(23,20,26,' + alpha.toFixed(3) + ')';
    head.style.borderBottomColor = 'rgba(23,20,26,' + (0.08 + 0.4 * ratio).toFixed(3) + ')';
  }

  function measureStuck() {
    var listTop = list.getBoundingClientRect().top;
    for (var i = 0; i < heads.length; i++) {
      var next = heads[i + 1];
      if (!next) { applyShadow(i, 0); continue; }
      var headH = heads[i].offsetHeight;
      var nextTop = next.getBoundingClientRect().top - listTop;
      var ratio = 1 - Math.min(1, Math.max(0, (nextTop - headH) / headH));
      ratio = Math.min(1, Math.max(0, ratio));
      applyShadow(i, ratio);
    }
  }

06誤字候補 — 編集距離を計算し「もしかして」行を出す計算

入力語と候補の両方をtoLowerCase().trim()で正規化した後、2次元dp表でレーベンシュタイン編集距離を求め、距離が1からTHRESHOLD(2)までなら「もしかして」行を出します。累積面積45.977%・面積最大42.202%・強度平均89.8・動いたフレーム7/23です。

誤字候補 — 編集距離を計算し「もしかして」行を出す計算
新しいタブで開く

06 · 誤字候補 — 編集距離を計算し「もしかして」行を出す計算 — 計測 累積面積 45.977% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

beforeinputCSS custom propertiesaria-expanded

dp[i][j]の三方向最小値で編集距離を積み上げるループ

正規化(toLowerCase + trim)は距離を計算する直前に一度だけ通すので、大文字小文字や前後の空白の違いだけで距離が広がることはありません(7/23は候補が確定するまでの静止による自然な値です)。

  var THRESHOLD = 2;

  function normalize(s) {
    return s.toLowerCase().trim();
  }

  function editDistance(a, b) {
    var m = a.length, n = b.length;
    var dp = [];
    for (var i = 0; i <= m; i++) { dp.push([i]); }
    for (var j = 0; j <= n; j++) { dp[0][j] = j; }
    for (i = 1; i <= m; i++) {
      for (j = 1; j <= n; j++) {
        if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1];
        else {
          dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
        }
      }
    }
    return dp[m][n];
  }

07急上昇バー — 検索回数を最大値比でスケールして描く順位バー

各検索語のcountをリスト内最大値で割ってratioを求め、CSSカスタムプロパティ--ratioに格納してtransform: scaleX(var(--ratio))でバーの幅を描きます。累積面積4.804%・面積最大0.774%・強度平均184.9・動いたフレーム12/23です。

急上昇バー — 検索回数を最大値比でスケールして描く順位バー
新しいタブで開く

07 · 急上昇バー — 検索回数を最大値比でスケールして描く順位バー — 計測 累積面積 4.804% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

transform: scaleXCSS custom propertiestransition

ratioを--ratioに渡すだけでデータ変化に追随するrender

ratio.toFixed(3)を--ratioに渡すだけで、データが変わっても同じscaleX(var(--ratio))の式が再び正しい幅を描きます。

  function render() {
    var rows = ranked.map(function (d, i) {
      var currRank = i + 1;
      var ratio = d.count / max;
      return '<li class="tr__row" role="option" style="--i:' + i + '">' +
        '<span class="tr__rank">' + currRank + '</span>' +
        '<span class="tr__q">' + d.q + '</span>' +
        '<span class="tr__delta">' + deltaIcon(d.prevRank, currRank) + '</span>' +
        '<span class="tr__track"><span class="tr__bar" style="--ratio:' + ratio.toFixed(3) + '"></span></span>' +
        '<span class="tr__count">' + d.count + '</span>' +
        '</li>';
    });
    list.innerHTML = rows.join('');
  }
  render();

08モバイルシート変身 — 画面が狭いとドロップダウンがボトムシートへ変わるメディアクエリ切替

matchMedia('(max-width: 340px)')で狭い画面を検知して同じリストDOMにis-sheetクラスを切り替え、visualViewportで実測した高さの差を目標値としてlerpで追いかけます。累積面積29.61%・面積最大16.469%・強度平均66.5・動いたフレーム13/23です。

モバイルシート変身 — 画面が狭いとドロップダウンがボトムシートへ変わるメディアクエリ切替
新しいタブで開く

08 · モバイルシート変身 — 画面が狭いとドロップダウンがボトムシートへ変わるメディアクエリ切替 — 計測 累積面積 29.61% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

visualViewporttransitionmatchMedia

current += (target - current) * 0.12の一次lerpでキーボードを追いかける

一次lerpが目標値へ指数的に近づくので、キーボードの開閉が瞬間的な段差ではなく滑らかな遷移に見えます。

  function tick(now) {
    if (now - lastFlip > 900) {
      target = target === 0 ? 1 : 0;
      lastFlip = now;
    }
    current += (target - current) * 0.12;
    sheet.style.setProperty('--kb', current.toFixed(4));
    if (playing) requestAnimationFrame(tick);
  }

09リデュースモーション — シマー・ステガーを切り、リストが即座に出る静的代替

各項目の登場遅延をindex * STEP_MSで計算し、matchMediaのprefers-reduced-motionか画面内スイッチがオンならSTEP_MSを0にしてリスト全体を一度に出します。累積面積30.961%・面積最大27.442%・強度平均47.1・動いたフレーム20/23です。

リデュースモーション — シマー・ステガーを切り、リストが即座に出る静的代替
新しいタブで開く

09 · リデュースモーション — シマー・ステガーを切り、リストが即座に出る静的代替 — 計測 累積面積 30.961% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

prefers-reduced-motiontransitionaria-expanded

STEP_MSを0に落とすだけでリストの式自体は変えない

STEP_MSをそのまま0に落とすだけで、リストを作る式自体は変えずに登場が同時に揃います。デバウンス待ち時間そのものは動きと無関係なので、この0化の対象にはしません。zip内のvanillaフォルダをそのまま開けば、8gshpuefを解凍キーにして09の完成コードも確認できます。

  function open() {
    var step = reduced ? 0 : STEP_MS;
    list.innerHTML = WORDS.map(function (w, i) {
      return '<li class="rm__item" role="option" style="--delay:' + (i * step) + 'ms">' + w + '</li>';
    }).join('');
    input.setAttribute('aria-expanded', 'true');
    requestAnimationFrame(function () {
      requestAnimationFrame(function () {
        list.classList.add('is-open');
      });
    });
  }

リストは計算だ — 部分一致検索・編集距離・順位スコアの式

部分一致ハイライトの式 — indexOfの繰り返しと大小無視の統一

指定した位置iから改めてindexOfを呼ぶことで、1つの単語の中に同じ文字列が複数回現れる場合でもすべての一致区間を見つけられます。比較前にtoLowerCaseで統一するので、大文字小文字の違いは一致に影響しません。

デバウンスチケットの式 — pendingIdと比較して古い応答を捨てる

タイマーが切れて応答が届いた時点のmyIdと、その時点の最新pendingIdを比較し、一致しなければ画面を更新せずに捨てます。これにより、遅れて届いた古い応答が新しい入力の結果を上書きすることがありません。

レーベンシュタイン編集距離のDP — dp[i][j]の三方向最小値

dp[i][j] = 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])という式が削除・挿入・置換のうち最も少ない手数を選び続け、最終的にdp[m][n]が入力語と候補の編集距離になります。

比率スケールとmod循環 — ratioと(i + delta + length) % length

順位バーはratio = count / maxを求めて--ratioに格納するだけで、データが変わっても同じscaleX(var(--ratio))の式が再び正しい幅を描きます。キーボード循環は(activeIndex + delta + length) % lengthで、Math.minやMath.maxで端を止める実装とは違い最後から最初へ自然に戻ります。

どこで壊れるか — モバイルキーボード・IME・スクロールの落とし穴

モバイルキーボードが目測を狂わせる

実機の仮想キーボードはvisualViewportのheightを変えるだけで、window.innerHeightそのものは変わりません。テキストボックス オートコンプリート 実装でこの差を見落とすと、キーボードが開いた後もリストの高さがpxで固定されたままキーボードの下に隠れます。

IME変換中に検索を走らせない

日本語入力のIME変換中はinputイベントが変換途中の文字列で何度も発火するため、確定前の未確定文字列のままデバウンスタイマーを刻んでしまうと、無意味な候補が一瞬表示されて消えます。compositionstart・compositionendで変換中かどうかを見て、確定後だけ検索を走らせる必要があります。

スクロールコンテナと固定ヘッダーの重なり

position: stickyのヘッダーはスクロールコンテナ自身がoverflow: autoを持っていないと張り付かず、親要素のどこかにoverflow: hiddenが挟まっているだけで機能全体が静かに壊れます。この3つの落とし穴を避けたコードは9項目の各セクションにすでに含まれています。

この9種、実際どこに置くものか

サイト上部の統合検索窓、通販サイトの商品検索、ヘルプセンターの文書検索 — この3つがオートコンプリートを最もよく求める場所です。01の一致ハイライトは商品名のように入力文字がどこに当たったかすぐ見せたい検索に合い、04の最近の検索チップは同じ語を何度も探すヘルプセンター検索に合います。

人気語が絶えず入れ替わる検索窓では、07の急上昇バーが今集まっている語を順位で見せます。3つの画面に共通するのは、1文字打つたびにリストを再計算しなければならない点で、デバウンスと順位計算を1つにまとめたこの構造がそのまま当てはまります。

シマーの時間と循環速度を落とすとアクセシビリティも整理される

デバウンス待ち時間 300ms→500ms

300msは速い入力にすぐ反応しますが、500msに伸ばすとリクエストが半分以下に減り、入力が落ち着いた後だけリストが変わる落ち着いた感触になります。

誤字判定の距離 ≤2→≤1

距離2までを許すと「aple」に「apple」と「maple」の両方が候補として付きますが、1に狭めると最も確実な訂正だけが残り、提案が慎重になります。

リスト最大表示数 8個→12個

8個は一目で全部見えますが、12個にするとスクロールが生まれ、05のグループ固定ヘッダーと07の急上昇バーの計算が本格的に活きてきます。

prefers-reduced-motionでも機能は止めない

matchMedia('(prefers-reduced-motion: reduce)')が真であればシマー・ステガー・遷移アニメーションだけを止め、デバウンス待ち時間・mod循環・編集距離計算のような機能自体はそのまま動かし続けます。

検索窓によくある質問3つ

オートコンプリート 実装方法でrole="combobox"は必須ですか

はい。role="combobox"・role="searchbox"・role="listbox"の3層構造を1つのdivにまとめたり、ネイティブのinputを消してカスタム要素に置き換えると、矢印キー操作とスクリーンリーダーの読み上げが最初から失われます。

検索窓 サジェストのハイライトは最初の一致だけで十分ですか

不十分です。indexOf(query, i)を1回だけ呼んで止めると、同じ単語に同じ文字が2回現れる場合に片方しか強調されません。見つかった位置から先を再検索し続けてすべての一致を集める必要があります。

zip内のコードはこの記事の9項目とそのまま同じですか

はい。zipのvanillaフォルダのindex.htmlをそのまま開けば同じ計算層のまま動き、DEBOUNCE_MS・THRESHOLD・STEP_MSの定数だけを書き換えれば自分のデザインに調整できます。

もっと読む

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

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