本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

検索のオーバーレイが全画面に広がり、候補が順に現れる

デモ

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

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ヘッダーの検索ボタンを押すと、ボタンの丸から面が広がって画面全体を覆い、虫めがねは × に変わります。広がりきる前に大きな入力欄が上がってきて下線が左から引かれ、「よく検索されるキーワード」の候補が上から順に現れます。候補を選ぶとその語で検索し、中身が消えてから面がボタンへ縮んで閉じます。

面は 0.7 秒の power3.inOut で広げ、中身は広がり始めて 0.45 秒後から、24px 下から 0.07 秒ずつずらして上げます。面が広がりきる前に中身が動き出すので、開く動きと候補の並びが一続きに見えます。閉じるときは同じタイムラインを 1.6 倍の速さで逆再生し、下の候補から消えてから面が縮みます。

面の形は clip-path の inset() で、ボタンの四辺から 0 まで、角の丸みもボタンの半径から 0 まで補間します。文字列は、0 から 1 へ動かす値から自分で組みます。今の値から GSAP に補間させると、ブラウザが inset(0px) のように縮めた書き方で返すので、数の並びが合わずに形が飛びます。広げる元の四辺は、開くたびにボタンの位置を測って決めます。

面は showModal() で開く dialog で、焦点の閉じ込め、背面の締め出し、閉じたあとの焦点の戻しが付いてきます。Esc は既定だと即座に閉じるので、cancel を止めて同じ動きで閉じます。入力欄は role="combobox" にして、↑↓ で移っている候補を aria-activedescendant で伝え、Enter で選びます。打つと候補を絞り込み、残った数を読み上げに知らせます。かな漢字変換を確定する Enter では動かさないよう、変換中のキーは受けません。

このデモでは、検索ボタンで開き、× で閉じ、候補を押すとその語を入れて閉じます(打ち込みと矢印・Esc のキーは、コードのほうで受けます)。閉じるボタンを開くボタンと同じ位置に置くと、押した所で × に変わって見えます。上から降ろしたいなら、広げる元を画面の上端の細い帯にします。記事や商品の多いメディア・ブログ・EC の検索に向いています。

コード

HTML
<!-- ヘッダーの検索ボタン。押すと、このボタンの丸から全画面の検索が広がる -->
<button class="search-open" type="button" aria-label="サイト内を検索" aria-haspopup="dialog" aria-controls="site-search">
  <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="7" cy="7" r="4.25" /><path d="M10.25 10.25 13.5 13.5" /></svg>
</button>

<!-- showModal() で開く dialog には、焦点の閉じ込め・背面の締め出し・Esc・閉じたあとの焦点の戻しが付いてくる -->
<dialog class="search" id="site-search" aria-label="サイト内検索">
  <button class="search__close" type="button" aria-label="検索を閉じる">
    <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8" /></svg>
  </button>
  <form class="search__form" role="search" action="/search/" method="get">
    <div class="search__field">
      <svg class="search__lens" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="7" cy="7" r="4.75" /><path d="M10.5 10.5 14 14" /></svg>
      <!-- 入力欄が候補の一覧を操る(combobox)。キーボードで移っている候補は aria-activedescendant で伝える -->
      <input class="search__input" type="search" name="q" role="combobox" aria-label="キーワード" aria-expanded="true" aria-controls="search-list" aria-autocomplete="list" placeholder="キーワードで記事を探す" autocomplete="off" autofocus>
      <span class="search__bar" aria-hidden="true"></span>
    </div>
  </form>
  <p class="search__label" id="search-label">よく検索されるキーワード</p>
  <ul class="search__list" id="search-list" role="listbox" aria-labelledby="search-label">
    <li class="search__option" id="keyword-motion" role="option"><span class="search__word">モーションデザインの基本</span><span class="search__count" aria-hidden="true">記事24件</span></li>
    <li class="search__option" id="keyword-scroll" role="option"><span class="search__word">スクロールアニメーション</span><span class="search__count" aria-hidden="true">記事18件</span></li>
    <li class="search__option" id="keyword-type" role="option"><span class="search__word">タイポグラフィ</span><span class="search__count" aria-hidden="true">記事15件</span></li>
    <li class="search__option" id="keyword-color" role="option"><span class="search__word">配色の決め方</span><span class="search__count" aria-hidden="true">記事9件</span></li>
    <li class="search__option" id="keyword-micro" role="option"><span class="search__word">マイクロインタラクション</span><span class="search__count" aria-hidden="true">記事12件</span></li>
    <li class="search__option" id="keyword-font" role="option"><span class="search__word">Webフォント</span><span class="search__count" aria-hidden="true">記事7件</span></li>
  </ul>
  <!-- 絞り込んだ候補の数を、読み上げに知らせる -->
  <p class="search__status" role="status"></p>
</dialog>

CSS
/* 線の丸のボタン(開く・閉じる)。色はヘッダーの文字の色を受け継ぐ */
.search-open,
.search__close {
  display: inline-grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1px solid currentColor;
  border-radius: 50%;
  background: none;
  color: inherit;
  cursor: pointer;
}

.search-open svg,
.search__close svg,
.search__lens {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

.search-open:focus-visible,
.search__close:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 3px;
}

/* 全画面の dialog。広がる形は JS が clip-path で描く */
.search {
  --ground: #262626;                   /* 検索の面 */
  --ink: #eeece6;                      /* 文字と線 */
  --mute: #898989;                     /* 入力例 */
  --label: #a8a6a0;                    /* 16px 未満の補足 */
  --line: rgba(238, 236, 230, 0.14);   /* 罫線 */
  --active: rgba(238, 236, 230, 0.08); /* 移っている候補の地 */
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  margin: 0;
  padding: 136px clamp(24px, 7.5vw, 96px) 48px;
  box-sizing: border-box;
  overflow-y: auto;
  border: 0;
  background-color: var(--ground);
  color: var(--ink);
}

.search::backdrop { background: none; }

/* 開くボタンと同じ所に置く(JS が top と left を入れる) */
.search__close { position: fixed; }

.search__field {
  position: relative;
  display: flex;
  align-items: center;
  gap: 16px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--line);
}

.search__lens { flex: none; width: 28px; height: 28px; }
.search__input { flex: 1; min-width: 0; padding: 0; border: 0; outline: none; background: none; color: inherit; font: inherit; font-size: clamp(24px, 4vw, 40px); font-weight: 700; letter-spacing: 0.02em; }
.search__input::placeholder { color: var(--mute); }
.search__input::-webkit-search-cancel-button { display: none; }

/* 開くときに左から引く下線。罫線の上に重ねる */
.search__bar { position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background-color: var(--ink); transform-origin: left; }

.search__label { margin: 40px 0 8px; color: var(--label); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; }
.search__list { margin: 0; padding: 0; list-style: none; }

.search__option {
  display: flex;
  align-items: center;
  gap: 16px;
  min-height: 60px;
  padding: 0 16px;
  border-bottom: 1px solid var(--line);
  font-size: clamp(16px, 2.4vw, 22px);
  font-weight: 700;
  letter-spacing: 0.02em;
  cursor: pointer;
}

.search__option[hidden] { display: none; } /* display: flex が hidden 属性に勝つので、絞り込みで外した候補を隠し直す */
.search__option[aria-selected="true"] { background-color: var(--active); }
.search__count { margin-left: auto; color: var(--label); font-size: 13px; font-weight: 400; letter-spacing: 0.04em; white-space: nowrap; }
.search__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で開け閉めする

document.querySelectorAll(".search").forEach((dialog) => {
  const opener = document.querySelector(`[aria-controls="${dialog.id}"]`);
  const closer = dialog.querySelector(".search__close");
  const form = dialog.querySelector(".search__form");
  const input = dialog.querySelector(".search__input");
  const status = dialog.querySelector(".search__status");
  const options = [...dialog.querySelectorAll(".search__option")];
  const wordOf = (option) => option.querySelector(".search__word").textContent;
  const shape = { p: 0 };
  let edges = [0, 0, 0, 0];
  let radius = 0;
  let hits = options;
  let active = -1;

  // 開くボタンの四辺から、画面いっぱいへ広げる。文字列は自分で組む(今の値から補間すると、ブラウザが縮めた書き方で返すので数が合わない)
  const clip = () => {
    const k = 1 - shape.p;
    dialog.style.clipPath = k <= 0 ? "none" : `inset(${edges.map((edge) => `${edge * k}px`).join(" ")} round ${radius * k}px)`;
  };

  // 開く: 面が広がり、× が回って出る。広がりきる前に入力欄・見出し・候補が順に上がり、下線が左から引かれる
  const open = gsap
    .timeline({ paused: true, onReverseComplete: () => dialog.close() })
    .to(shape, { p: 1, duration: 0.7, ease: "power3.inOut", onUpdate: clip }, 0)
    .fromTo(closer, { rotation: -90, opacity: 0 }, { rotation: 0, opacity: 1, duration: 0.3, ease: "power2.out" }, 0.1)
    .fromTo(dialog.querySelectorAll(".search__field, .search__label, .search__option"), { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.55, ease: "power3.out", stagger: 0.07 }, 0.45)
    .fromTo(dialog.querySelector(".search__bar"), { scaleX: 0 }, { scaleX: 1, duration: 0.6, ease: "power3.inOut" }, 0.55);

  // キーボードで移っている候補。焦点は入力欄に置いたまま、aria-activedescendant で伝える(-1 は入力欄に戻った状態)
  const activate = (i) => {
    active = i;
    options.forEach((option) => option.setAttribute("aria-selected", String(option === hits[i])));
    input.setAttribute("aria-activedescendant", hits[i]?.id ?? "");
  };

  // 打った語を含む候補に絞る
  const filter = () => {
    const q = input.value.trim().toLowerCase();
    hits = options.filter((option) => wordOf(option).toLowerCase().includes(q));
    options.forEach((option) => (option.hidden = !hits.includes(option)));
    status.textContent = `候補${hits.length}件`;
    activate(-1);
  };

  const show = () => {
    const r = opener.getBoundingClientRect();
    edges = [r.top, innerWidth - r.right, innerHeight - r.bottom, r.left];
    radius = r.height / 2;
    Object.assign(closer.style, { top: `${r.top}px`, left: `${r.left}px` }); // 押した所で × に変わって見えるように
    input.value = "";
    filter();
    shape.p = 0;
    clip();
    dialog.showModal(); // 焦点は入力欄(autofocus)へ移り、閉じると検索ボタンへ戻る
    open.timeScale(SPEED).play(0);
  };
  const hide = () => open.timeScale(SPEED * 1.6).reverse();

  // 候補を選ぶと、その語で検索する(ページを移らない作りでも閉じるよう、submit で閉じる)
  const choose = (option) => {
    input.value = wordOf(option);
    form.requestSubmit();
  };

  opener.addEventListener("click", show);
  closer.addEventListener("click", hide);
  form.addEventListener("submit", hide);
  // Esc は既定だと即座に閉じるので、いったん止めて同じ動きで閉じる
  dialog.addEventListener("cancel", (event) => {
    event.preventDefault();
    hide();
  });
  input.addEventListener("input", filter);
  input.addEventListener("keydown", (event) => {
    if (event.isComposing) return; // かな漢字変換を確定する Enter や、変換候補を選ぶ矢印では動かさない
    const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
    if (step) {
      event.preventDefault(); // 矢印で入力欄のキャレットを動かさない
      activate(gsap.utils.wrap(-1, hits.length, active + step));
    } else if (event.key === "Enter" && hits[active]) {
      event.preventDefault();
      choose(hits[active]);
    }
  });
  options.forEach((option) => {
    option.addEventListener("pointermove", () => hits[active] !== option && activate(hits.indexOf(option)));
    option.addEventListener("click", () => choose(option));
  });
});

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

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