デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- 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 の検索に向いています。
コード
<!-- ヘッダーの検索ボタン。押すと、このボタンの丸から全画面の検索が広がる -->
<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>
/* 線の丸のボタン(開く・閉じる)。色はヘッダーの文字の色を受け継ぐ */
.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; }
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));
});
});