本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ナビゲーション

ナビゲーションの検索アイコンを押すと、左へ伸びて入力欄に変わる

デモ

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

仕様

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

しくみと調整

ヘッダーの右端にある線の丸の検索ボタンを押すと、右端を固定したまま左へ伸びて、入力欄になります。虫めがねは欄の左端に置いてあるので、伸びるのに合わせて左へ移り、ナビのリンクは右から順に左へ抜けて消えます。伸びきる直前に、入力欄の文字と、回りながら × が出ます。× を押すと、同じ動きを逆にたどって丸に戻ります。

幅は 40px の丸から 640px まで、0.6 秒の power3.inOut で補間します。リンクは右から 0.04 秒ずつずらし、12px 左へ動かしながら消します。欄の地を不透明にしておくと、消えきる前のリンクを伸びてくる欄が覆うので、押し出されたように見えます。閉じるときは同じタイムラインを 1.4 倍の速さで逆再生するので、× と文字が消えてから縮み、最後にリンクが戻ります。

幅は CSS の calc で「閉じた幅 + 開き具合 ×(開いた幅 − 閉じた幅)」と組み、スクリプトは開き具合の CSS 変数だけを 0 から 1 へ動かします。開いた幅をロゴの手前までに収める式も CSS に書けるので、画面の幅が変わっても、開いたまま崩れません。閉じている間の入力欄は inert にして、見えない欄にタブで入らないようにします。

開くとすぐ焦点を入力欄へ移すので、欄が見えきる前から打てます。Esc か × で閉じると、焦点は検索ボタンへ戻ります。かな漢字変換を確定する Esc では閉じません。何も打たずに Tab で抜けたときや、ほかの所を押したときも閉じ、打った文字は閉じきってから消します。検索ボタンの aria-expanded で、開いているかを伝えます。このデモでは、検索ボタンで開き、× で閉じます(打ち込みとキーの操作は、コードのほうで受けます)。

狭い画面では、リンクを 2 段目に回して残し、検索欄はロゴの上まで伸ばします。開いた幅と速さを変えるだけで、ブログ・EC・ドキュメントサイトのヘッダー検索に合わせられます。動きを減らす設定では、開閉を 20 倍の速さにしてほぼ一瞬で切り替えます。

コード

HTML
<header class="search-header">
  <a class="search-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>

  <nav class="search-header__nav" aria-label="メイン">
    <ul>
      <li><a href="/" aria-current="page">記事</a></li>
      <li><a href="/features/">特集</a></li>
      <li><a href="/writers/">執筆者</a></li>
      <li><a href="/about/">運営について</a></li>
    </ul>
  </nav>

  <!-- 閉じている間は線の丸の検索ボタン。押すと右端を固定したまま左へ伸びて、入力欄になる -->
  <form class="search-box" role="search" action="/search/" method="get">
    <button class="search-box__open" type="button" aria-label="記事を検索" aria-expanded="false" aria-controls="search-q">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="10.5" cy="10.5" r="6.5" /><path d="M15.5 15.5 20 20" /></svg>
    </button>
    <!-- 閉じている間は inert で、タブ移動と読み上げから外す -->
    <input class="search-box__input" id="search-q" type="search" name="q" placeholder="記事やトピックを検索" aria-label="キーワード" autocomplete="off" inert>
    <button class="search-box__close" type="button" aria-label="検索を閉じる">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 7l10 10M17 7 7 17" /></svg>
    </button>
  </form>
</header>

CSS
.search-header {
  --ground: #111111;                /* ヘッダーの地 */
  --surface: #262626;               /* 入力欄の地 */
  --ink: #eeece6;                   /* 文字・線・今のページのリンク */
  --mute: #a8a6a0;                  /* ほかのリンク・入力例 */
  --side: clamp(16px, 7.5vw, 96px); /* 左右の余白 */
  position: relative; /* 検索欄の位置の基準 */
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  padding: 32px var(--side);
  background-color: var(--ground);
  color: var(--ink);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
}

.search-header a { color: inherit; text-decoration: none; }
.search-header__logo { margin-right: auto; font-size: 18px; font-weight: 700; line-height: 48px; letter-spacing: 0.12em; white-space: nowrap; }

/* リンクは検索ボタンの左に右寄せで並べる。右に、検索ボタン(40px)と間(32px)を空ける */
.search-header__nav ul { display: flex; gap: 32px; margin: 0 72px 0 32px; padding: 0; list-style: none; white-space: nowrap; }
.search-header__nav a { display: block; color: var(--mute); }
.search-header__nav a[aria-current="page"] { color: var(--ink); }
.search-header a:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; border-radius: 2px; }

/* 右端を固定して、左へ伸ばす。幅は --open(0〜1)で、閉じた丸(40px)から開いた幅までを補間する。
   開いた幅は 640px まで。ロゴの幅と間(--logo-space)を残して、ロゴには重ねない */
.search-box {
  --open: 0;
  --logo-space: 280px;
  --wide: min(640px, 100% - 2 * var(--side) - var(--logo-space));
  position: absolute;
  top: 36px;
  right: var(--side);
  width: calc(40px + var(--open) * (var(--wide) - 40px));
  height: 40px;
  margin: 0;
  overflow: hidden;
  box-sizing: border-box;
  border-radius: 20px;
  /* 地は不透明にして、伸びながら消えかけのリンクを覆う。枠は文字の色(開くと、フォーカス中の入力欄の枠になる) */
  background-color: var(--surface);
  box-shadow: inset 0 0 0 1px var(--ink);
}

.search-box__open,
.search-box__close {
  position: absolute;
  top: 0;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--ink);
  cursor: pointer;
}

.search-box__open { left: 0; }
.search-box__close { right: 0; visibility: hidden; opacity: 0; }
/* 箱の overflow: hidden で切れないよう、焦点の枠は内側に描く */
.search-box button:focus-visible { outline: 2px solid var(--ink); outline-offset: -5px; }
.search-box svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; }
.search-box__close svg { width: 24px; height: 24px; stroke-width: 2; }

.search-box__input {
  position: absolute;
  top: 0;
  left: 40px;
  width: calc(100% - 80px);
  height: 40px;
  padding: 0;
  border: 0;
  outline: none;
  background: none;
  color: var(--ink);
  caret-color: var(--ink);
  font: inherit;
  font-size: 15px;
  font-weight: 400;
  letter-spacing: 0.04em;
  opacity: 0;
}

.search-box__input::placeholder { color: var(--mute); opacity: 1; }
.search-box__input::-webkit-search-cancel-button { display: none; } /* 閉じるのは右の × だけにする */

/* 狭い画面では、リンクを 2 段目に回す。検索欄は 1 段目で、ロゴの上まで伸びる */
@media (max-width: 860px) {
  .search-header__nav { order: 1; flex-basis: 100%; }
  .search-header__nav ul { gap: 24px; margin: 8px 0 0; }
  .search-box { --logo-space: 0px; }
}

JavaScript
import { gsap } from "gsap";

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

document.querySelectorAll(".search-box").forEach((box) => {
  const opener = box.querySelector(".search-box__open");
  const closer = box.querySelector(".search-box__close");
  const input = box.querySelector(".search-box__input");
  const links = box.closest(".search-header")?.querySelectorAll(".search-header__nav a") ?? [];

  let isOpen = false;
  let open;
  // 開閉は 1 本のタイムライン。閉じるときは逆再生するので、× と文字が消えてから縮み、最後にリンクが戻る。
  // リンクを押し出して消すのは、検索欄と同じ段に並ぶ広い画面だけ(狭い画面では、2 段目のリンクを残す)
  gsap.matchMedia().add({ wide: "(min-width: 861px)", narrow: "(max-width: 860px)" }, ({ conditions }) => {
    open = gsap
      .timeline({ paused: true, onReverseComplete: () => (input.value = "") }) // 打った文字は、閉じきってから消す
      .fromTo(box, { "--open": 0 }, { "--open": 1, duration: 0.6, ease: "power3.inOut" }, 0.05)
      .fromTo(input, { opacity: 0 }, { opacity: 1, duration: 0.25, ease: "power1.out" }, 0.45)
      .fromTo(closer, { autoAlpha: 0, rotation: -90 }, { autoAlpha: 1, rotation: 0, duration: 0.35, ease: "back.out(2)" }, 0.45);
    // リンクは右から順に左へ抜ける(伸びてくる検索欄に押し出されるように)
    if (conditions.wide) open.fromTo(links, { autoAlpha: 1, x: 0 }, { autoAlpha: 0, x: -12, duration: 0.25, ease: "power2.in", stagger: { each: 0.04, from: "end" } }, 0);
    if (isOpen) open.progress(1); // 開いたまま画面の幅が変わったら、開いた形で作り直す
  });

  const setOpen = (next, returnFocus = true) => {
    if (next === isOpen) return;
    isOpen = next;
    opener.setAttribute("aria-expanded", String(next));
    // 閉じるときは、入力欄を inert にする前に焦点を検索ボタンへ戻す(先に inert にすると、焦点が行き場を失う)
    if (!next && returnFocus) opener.focus();
    input.inert = !next;
    if (next) {
      input.focus(); // 見え始める前から打てるように、すぐ焦点を移す
      open.timeScale(SPEED).play();
    } else {
      open.timeScale(SPEED * CLOSE_SPEED).reverse();
    }
  };

  // 開いているあいだに虫めがねを押したら、入力欄へ戻す
  opener.addEventListener("click", () => (isOpen ? input.focus() : setOpen(true)));
  closer.addEventListener("click", () => setOpen(false));
  // Esc で閉じる。かな漢字変換を確定する Esc では閉じない
  box.addEventListener("keydown", (event) => {
    if (event.key !== "Escape" || !isOpen || event.isComposing || event.keyCode === 229) return;
    event.preventDefault(); // 検索欄の既定(文字だけを消す)を止める
    setOpen(false);
  });
  // 何も打たずに外へ出たら閉じる(外を押したときも、Tab で抜けたときも)
  box.addEventListener("focusout", (event) => {
    if (!box.contains(event.relatedTarget) && !input.value) setOpen(false, false);
  });
});

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

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