本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
アイコン

虫めがねのアイコンの柄が伸びて × になり、検索語を消す印に替わる

デモ

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

仕様

用途
アイコン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

検索欄の右端の虫めがねは、欄に 1 字でも入ると × に変わり、検索語を消すボタンになります。輪は中心のまま半径だけが縮んで柄の線の上に消え、柄は 45° の向きを保ったまま欄の内側へ伸びて × の 1 本になります。柄には同じ線をもう 1 本重ねてあり、伸びきる少し前から、その 1 本だけが同じ中心で 90° 回って交わります。形はすべて「輪の大きさ・柄の伸び・2 本目の回り」の 3 つの値から描くので、動いている途中で消しても、今の形から続けて戻ります。

× へは、輪を 0.2 秒(power2.in)でしぼませ、0.08 秒後から柄を 0.34 秒(power3.inOut)で伸ばし、0.26 秒後から 2 本目を 0.42 秒の back.out(1.7) で回します。交わるところで少し行き過ぎるので、× がはまった手応えが出ます。戻すときは、2 本目 → 柄 → 輪の逆の順にし、行き過ぎるのは最後に弾んで出る輪だけにします。

同じタイムラインを逆再生で戻すと、回っていた 2 本目が先に跳ねてから戻り、落ち着かない動きになります。戻りは行き過ぎない ease で別に組みます。また、半径 0 の円も丸い線端のせいで点が残るので、輪は小さくなったら濃さも落とします。動かすのは、欄が空かどうかが変わったときだけにし、字を足すたびには動かしません。

空のあいだの虫めがねはただの飾りなので、ボタンに inert を付けて、押せず読み上げにも出ないようにします。字が入ると外れ、名前は「検索語を消す」になります。× を押したら、続けて打てるように焦点を欄へ戻します。ブラウザが検索欄に出す × は隠し、Esc でも消せるようにしています。動きを減らす設定では、形を一度に替えます。

3 本線が寄って回るメニューのアイコンは同じ長さの線の組み替えですが、こちらは輪が消えて線が伸びるので、「探す」から「消す」へ役目が替わったことが形で伝わります。欄そのものは伸び縮みしません。サイト内検索・一覧の絞り込み・店名や住所の入力など、打った語を消す操作がある欄に向いています。このデモでは、欄を押すと例の語が 1 字ずつ入り、× で消えます(キーボードの入力は、コードのほうで受けます)。

コード

HTML
<!-- 検索欄。右端の虫めがねは、文字が入ると × の「検索語を消す」ボタンに変わる -->
<form class="search" role="search" action="/search/">
  <label class="search__label" for="site-search">サイト内検索</label>
  <input class="search__input" id="site-search" type="search" name="q" placeholder="キーワードを入力" autocomplete="off" />
  <!-- 空のあいだはただの虫めがねなので inert にし、押せず読み上げにも出ないようにする -->
  <button class="search__clear" type="button" aria-label="検索語を消す" inert>
    <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      <circle class="search__ring" cx="10.5" cy="10.5" r="6.5" />
      <!-- 虫めがねの柄を 2 本重ねておき、× では 2 本目だけが 90° 回って交わる -->
      <line class="search__line" x1="15.2" y1="15.2" x2="20.5" y2="20.5" />
      <line class="search__line" x1="15.2" y1="15.2" x2="20.5" y2="20.5" />
    </svg>
  </button>
</form>

CSS
.search {
  --ink: #262626;                 /* 文字・アイコン・フォーカスの枠 */
  --mute: #898989;                /* 入力例 */
  --surface: #ffffff;             /* 欄の地 */
  --rule: rgba(38, 38, 38, 0.12); /* 休んでいる枠 */
  --hover: rgba(38, 38, 38, 0.06); /* × に重ねたときの丸 */
  --height: 56px;
  position: relative;
  width: min(100%, 360px);
  color: var(--ink);
}

/* ラベルは読み上げ用に残して、見た目からは隠す */
.search__label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.search__input {
  box-sizing: border-box;
  width: 100%;
  height: var(--height);
  padding: 0 64px 0 24px;
  border: 0;
  border-radius: 999px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--rule);
  color: inherit;
  font-family: inherit;
  font-size: 17px;
  letter-spacing: 0.04em;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}

.search__input::placeholder {
  color: var(--mute);
}

/* フォーカスは枠を文字の色にして見せる */
.search__input:focus {
  box-shadow: inset 0 0 0 1px var(--ink);
}

/* ブラウザの × は隠して、アイコンの × だけにする */
.search__input::-webkit-search-cancel-button {
  -webkit-appearance: none;
  appearance: none;
}

.search__clear {
  position: absolute;
  top: calc((var(--height) - 40px) / 2);
  right: 8px;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: inherit;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

/* 空のあいだ(inert)は、押せない飾りの虫めがね */
.search__clear[inert] {
  cursor: default;
}

.search__clear:not([inert]):hover {
  background: var(--hover);
}

.search__clear:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

/* 24 グリッドで線幅 2(24px で描けば 2px)。線端は丸 */
.search__clear svg {
  width: 24px;
  height: 24px;
  overflow: visible;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

JavaScript
import { gsap } from "gsap";

// 形の動き: [値, 行き先, 始まり, 長さ, ease]。× へは輪がしぼみ、柄が伸び、最後に 2 本目が回って交わる
const TO_CLEAR = [
  ["ring", 0, 0, 0.2, "power2.in"],
  ["ext", 1, 0.08, 0.34, "power3.inOut"],
  ["turn", 1, 0.26, 0.42, "back.out(1.7)"],
];
// 虫めがねへは、行き過ぎない ease で線を戻してから、輪を弾ませて出す
const TO_SEARCH = [
  ["turn", 0, 0, 0.26, "power2.in"],
  ["ext", 0, 0.14, 0.32, "power3.inOut"],
  ["ring", 1, 0.34, 0.4, "back.out(2)"],
];
// 24 グリッドの形。柄(15.2,15.2 → 20.5,20.5)の中心と半分の長さ、× の 1 本の中心と半分の長さ
const HANDLE = { x: 17.85, y: 17.85, half: 3.75 };
const CROSS = { x: 12, y: 12, half: 7.78 };
const RADIUS = 6.5;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lerp = (a, b, k) => a + (b - a) * k;

document.querySelectorAll(".search").forEach((form) => {
  const input = form.querySelector(".search__input");
  const clear = form.querySelector(".search__clear");
  const ring = form.querySelector(".search__ring");
  const lines = form.querySelectorAll(".search__line");

  const state = { ring: 1, ext: 0, turn: 0 };
  const draw = () => {
    // 輪は中心のまま縮める。長さ 0 の円も丸い線端で点になるので、小さくなったら濃さも落とす
    ring.setAttribute("r", String(Math.max(0, RADIUS * state.ring)));
    ring.style.opacity = String(Math.min(1, state.ring * 6));
    const x = lerp(HANDLE.x, CROSS.x, state.ext);
    const y = lerp(HANDLE.y, CROSS.y, state.ext);
    const half = lerp(HANDLE.half, CROSS.half, state.ext);
    // 1 本目は 45° のまま伸び、2 本目は同じ中心で 90° 回る
    lines.forEach((line, i) => {
      const a = ((45 + (i === 1 ? 90 * state.turn : 0)) * Math.PI) / 180;
      line.setAttribute("x1", String(x - half * Math.cos(a)));
      line.setAttribute("y1", String(y - half * Math.sin(a)));
      line.setAttribute("x2", String(x + half * Math.cos(a)));
      line.setAttribute("y2", String(y + half * Math.sin(a)));
    });
  };

  let shown = false;
  let anim = null;
  const update = () => {
    const filled = input.value !== "";
    if (filled === shown) return; // 空かどうかが変わったときだけ動かす
    shown = filled;
    clear.inert = !filled;
    anim?.kill();
    anim = gsap.timeline({ onUpdate: draw });
    for (const [key, to, at, duration, ease] of filled ? TO_CLEAR : TO_SEARCH) anim.to(state, { [key]: to, duration, ease }, at);
    // 動きを減らす設定では、形を一度に替える
    if (reduceMotion) anim.progress(1);
  };

  input.addEventListener("input", update);
  // Esc で消す(type="search" で Esc が効かないブラウザもある)
  input.addEventListener("keydown", (e) => {
    if (e.key !== "Escape" || e.isComposing || input.value === "") return;
    e.preventDefault();
    input.value = "";
    update();
  });
  clear.addEventListener("click", () => {
    input.value = "";
    update();
    input.focus(); // 消したら、続けて打てるように欄へ戻す
  });
  // 戻る操作や自動入力で、初めから文字が入っていることがある
  update();
  draw();
});

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

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