デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- 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 字ずつ入り、× で消えます(キーボードの入力は、コードのほうで受けます)。
コード
<!-- 検索欄。右端の虫めがねは、文字が入ると × の「検索語を消す」ボタンに変わる -->
<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>
.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;
}
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();
});