デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- 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 倍の速さにしてほぼ一瞬で切り替えます。
コード
<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>
.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; }
}
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);
});
});