本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

検索欄に読みを打つと候補が絞られ、残った行が新しい順番へ滑る

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

打つたびに、その文字を読みに含む候補だけを残し、「一致が読みのどこから始まるか」で並べ替えます。候補は市区町村の名前と読みのかなを持ち、読みで引きます。読みの頭から一致する候補が上へ、途中で一致する候補が下へ回るので、打ち進めるほど目当てのものが上がってきます。「ひ」では上にいた東広島市が、「ひろ」で帯広市の下へ回るように、合わない候補を消して詰めるだけでなく、残った候補の順番そのものが入れ替わるのが、この作りの要です。

行は絶対位置で置いてあり、並びが変わったら前の位置から新しい位置へ滑らせます。移すのは0.3秒の power3.inOut、外れる行は0.25秒で0.92倍まで縮めながら薄くします。新しく出てくる行は10px上から、上の行から0.035秒ずつずらして降ります。パネルの高さも行数から出して0.35秒で動かすので、行の移動と枠の伸び縮みが1つの動きに見えます。一致した部分は、読みの中で明暗を反転した札にします。

高さを動かすのは、余白を持たない外側の箱にします。余白のある ul そのものを0まで縮めても、余白の分の帯が残ります。日本語の入力では、変換を確定するEnterにも keydown が届くので、isComposing を見て受け流します(Safariでは確定のときに false になるので、keyCodeの229も見ます)。読みはひらがなで持ち、カタカナで打たれてもひらがなにそろえて引きます。変換して漢字で打ったときは名前のほうで引き、名前に札を付けます。

欄は role="combobox"、候補は listbox にして、焦点は欄に置いたまま、選んでいる行を aria-activedescendant で伝えます。↑↓で移り、Enterで決め、Escで閉じます。候補の件数は見えない領域に書いて読み上げに届け、閉じている候補は hidden にして、読み上げとTabの行き先から外します。

一致の判定は部分一致です。滑る時間とずらしの幅がつまみで、ずらしを広げると1行ずつ並んでいく感じになります。動きを減らす設定では、ほぼ一瞬で切り替えます。住所の市区町村や駅名の補完、タグや商品の検索に向いています。

コード

HTML
<!-- 打った文字で候補を絞る入力補完。候補は名前と読みを持ち、並び順は「一致がどこから始まるか」で決める -->
<form class="ship" action="/checkout/" method="post">
  <div class="combo">
    <label class="combo__label" for="city">市区町村</label>
    <div class="combo__field">
      <!-- 焦点は欄に置いたまま、選んでいる候補は aria-activedescendant で伝える -->
      <input class="combo__input" id="city" name="city" type="text" role="combobox" autocomplete="off"
        aria-expanded="false" aria-controls="city-list" aria-autocomplete="list" placeholder="市区町村名を入力">
      <button class="combo__clear" type="button" hidden>
        <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg>
        <span class="combo__sr">入力を消す</span>
      </button>
    </div>
    <!-- 高さを伸ばすのは余白を持たない外側の箱(余白のある ul を 0 にしても、余白の帯が残る) -->
    <div class="combo__panel">
      <ul class="combo__list" id="city-list" role="listbox" aria-label="市区町村の候補" hidden>
        <li class="combo__row" id="c-hiroshima" role="option"><span class="combo__name">広島市</span><span class="combo__kana">ひろしま</span><span class="combo__area">広島県</span></li>
        <li class="combo__row" id="c-higashihiroshima" role="option"><span class="combo__name">東広島市</span><span class="combo__kana">ひがしひろしま</span><span class="combo__area">広島県</span></li>
        <li class="combo__row" id="c-hirosaki" role="option"><span class="combo__name">弘前市</span><span class="combo__kana">ひろさき</span><span class="combo__area">青森県</span></li>
        <li class="combo__row" id="c-himeji" role="option"><span class="combo__name">姫路市</span><span class="combo__kana">ひめじ</span><span class="combo__area">兵庫県</span></li>
        <li class="combo__row" id="c-hitachi" role="option"><span class="combo__name">日立市</span><span class="combo__kana">ひたち</span><span class="combo__area">茨城県</span></li>
        <li class="combo__row" id="c-hikone" role="option"><span class="combo__name">彦根市</span><span class="combo__kana">ひこね</span><span class="combo__area">滋賀県</span></li>
        <li class="combo__row" id="c-obihiro" role="option"><span class="combo__name">帯広市</span><span class="combo__kana">おびひろ</span><span class="combo__area">北海道</span></li>
        <li class="combo__row" id="c-asahikawa" role="option"><span class="combo__name">旭川市</span><span class="combo__kana">あさひかわ</span><span class="combo__area">北海道</span></li>
      </ul>
    </div>
    <p class="combo__count" role="status"></p>
  </div>
</form>

CSS
/* 暗い地の入力補完。候補の行は絶対位置で置き、並びが変わったら位置だけを動かす */
.ship {
  --ground: #111111;                   /* フォームの地 */
  --ink: #eeece6;                      /* 文字と焦点の枠 */
  --label: #a8a6a0;                    /* 欄の名前・地域名(16px 未満の補足) */
  --surface: #262626;                  /* 候補のパネル */
  --line: rgba(238, 236, 230, 0.14);   /* 休みの枠とパネルの縁 */
  --active: rgba(238, 236, 230, 0.07); /* 選んでいる行の地 */
  padding: 32px;
  background: var(--ground);
  color: var(--ink);
}

.combo { position: relative; max-width: 544px; }
.combo__label { display: block; margin-bottom: 8px; color: var(--label); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; }
.combo__field { position: relative; }

.combo__input {
  box-sizing: border-box; width: 100%; height: 56px; padding: 0 56px 0 16px;
  border: 0; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--line);
  background: none; color: var(--ink); font: inherit; font-size: 18px; letter-spacing: 0.04em;
}
.combo__input::placeholder { color: #898989; }
.combo__input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--ink); }

.combo__clear {
  position: absolute; right: 12px; top: 50%; display: grid; place-items: center;
  width: 32px; height: 32px; margin-top: -16px; padding: 0;
  border: 0; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--line);
  background: none; color: inherit; cursor: pointer;
}
.combo__clear[hidden] { display: none; }
.combo__clear svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.combo__sr, .combo__count { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

/* 高さを動かすのは、余白を持たないこの箱 */
.combo__panel { height: 0; margin-top: 8px; overflow: hidden; }
.combo__list {
  position: relative; box-sizing: border-box; height: 100%; margin: 0; padding: 8px;
  border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); list-style: none;
}

.combo__row {
  position: absolute; left: 8px; right: 8px; top: 0;
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  height: 40px; padding: 0 16px; border-radius: 4px;
  transform-origin: left center; cursor: pointer;
}
.combo__row[aria-selected="true"] { background: var(--active); }
.combo__name { flex: none; min-width: 5em; font-size: 16px; font-weight: 500; letter-spacing: 0.04em; white-space: nowrap; } /* 読みの列がそろうよう、5 字ぶん取る */
.combo__kana { margin-right: auto; color: var(--label); font-size: 14px; letter-spacing: 0.04em; white-space: nowrap; }
.combo__area { color: var(--label); font-size: 13px; letter-spacing: 0.04em; white-space: nowrap; }
/* 一致した部分は明暗を反転した札にする。左右の負の余白で、札が付いても文字がずれないようにする */
.combo__row mark { margin: 0 -2px; padding: 0 2px; border-radius: 2px; background: rgba(238, 236, 230, 0.92); color: var(--ground); }

@media (max-width: 480px) {
  .ship { padding: 24px 16px; }
  .combo__input { font-size: 16px; }
  .combo__area { display: none; }
}

JavaScript
import { gsap } from "gsap";

// 動きを減らす設定では、ほぼ一瞬で切り替える
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const T = reduce ? 0.02 : 1;

document.querySelectorAll(".combo").forEach((combo) => {
  const input = combo.querySelector(".combo__input");
  const panel = combo.querySelector(".combo__panel");
  const list = combo.querySelector(".combo__list");
  const rows = [...list.querySelectorAll(".combo__row")];
  const clear = combo.querySelector(".combo__clear");
  const count = combo.querySelector(".combo__count");
  // 候補の名前と読みは HTML に書いてある。寸法も CSS で決めた値を測って使う(測るあいだだけ hidden を外す)
  const texts = (part) => rows.map((row) => row.querySelector(part).textContent);
  const names = texts(".combo__name");
  const kanas = texts(".combo__kana");
  list.hidden = false;
  const rowH = rows[0].offsetHeight;
  const pad = parseFloat(getComputedStyle(list).paddingTop);
  list.hidden = true;
  let shown = [];
  let active = -1;

  // 一致した部分だけを mark で囲む(読みで一致したら読みに、漢字で一致したら名前に付ける)
  const paint = (row, i, hit, len) => {
    row.querySelector(".combo__name").textContent = names[i];
    row.querySelector(".combo__kana").textContent = kanas[i];
    const text = hit.kana ? kanas[i] : names[i];
    const mark = document.createElement("mark");
    mark.textContent = text.slice(hit.at, hit.at + len);
    row.querySelector(hit.kana ? ".combo__kana" : ".combo__name").replaceChildren(text.slice(0, hit.at), mark, text.slice(hit.at + len));
  };

  const select = (k) => {
    active = k;
    rows.forEach((row) => row.setAttribute("aria-selected", "false"));
    if (k < 0) return input.removeAttribute("aria-activedescendant");
    shown[k].setAttribute("aria-selected", "true");
    input.setAttribute("aria-activedescendant", shown[k].id);
  };

  const apply = (open = true) => {
    // カタカナで打っても引けるよう、ひらがなにそろえてから読みと名前の両方で探す
    const q = input.value.trim().replace(/[ァ-ヶ]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60));
    const find = (i) => (kanas[i].indexOf(q) >= 0 ? { i, at: kanas[i].indexOf(q), kana: true } : { i, at: names[i].indexOf(q), kana: false });
    const hits = q ? names.map((_, i) => find(i)).filter((h) => h.at >= 0) : [];
    hits.sort((a, b) => a.at - b.at || a.i - b.i); // 一致が前から始まる候補を上にする
    const rank = new Map(hits.map((h, k) => [h.i, k]));
    if (!open) hits.length = 0;
    if (hits.length) list.hidden = false;
    rows.forEach((row, i) => {
      const k = rank.get(i);
      if (k === undefined || !open) {
        // 外れた候補は、縮みながら薄くなってから並びを抜ける(overwrite で前の動きを止め、古い onComplete が後から隠さないようにする)
        gsap.to(row, { opacity: 0, scale: 0.92, duration: 0.25 * T, ease: "power2.out", overwrite: true, onComplete: () => (row.hidden = true) });
        return;
      }
      const fresh = row.hidden;
      row.hidden = false;
      paint(row, i, hits.find((h) => h.i === i), q.length);
      if (fresh) gsap.set(row, { y: pad + k * rowH - 10, opacity: 0, scale: 1 });
      // 残った候補は、前の位置から新しい位置へ滑る
      gsap.to(row, { y: pad + k * rowH, opacity: 1, scale: 1, duration: (fresh ? 0.4 : 0.3) * T, delay: fresh ? k * 0.035 * T : 0, ease: fresh ? "power3.out" : "power3.inOut", overwrite: true });
    });
    shown = hits.map((h) => rows[h.i]);
    // 閉じきってから listbox を外す(先に外すと、縮むあいだ中身が消えて見える)
    const h = hits.length ? pad * 2 + hits.length * rowH : 0;
    gsap.to(panel, { height: h, duration: 0.35 * T, ease: "power3.out", overwrite: true, onComplete: () => (list.hidden = h === 0) });
    input.setAttribute("aria-expanded", String(hits.length > 0));
    clear.hidden = input.value === "";
    count.textContent = q && open ? `候補${hits.length}件` : "";
    select(hits.length ? 0 : -1);
  };

  input.addEventListener("input", () => apply());
  input.addEventListener("keydown", (event) => {
    // かな漢字変換を確定する Enter では動かさない(Safari は keyCode でも見分ける)
    if (event.isComposing || event.keyCode === 229) return;
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault();
      if (shown.length) select((active + (event.key === "ArrowDown" ? 1 : shown.length - 1)) % shown.length);
    } else if (event.key === "Enter" && active >= 0) {
      event.preventDefault();
      shown[active].click();
    } else if (event.key === "Escape") {
      apply(false);
    }
  });
  const commit = (value) => {
    input.value = value;
    apply(false);
    input.focus();
  };
  rows.forEach((row) => row.addEventListener("click", () => commit(row.querySelector(".combo__name").textContent)));
  clear.addEventListener("click", () => commit(""));
  apply(false);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。