デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
「絞り込み」のボタンにカーソルを重ねると、3本の線に付いた丸いハンドルが、上から左・右・左と互い違いに滑ります。押せばハンドルは線の中央へ寄りながら縮んで消え、線が × に組み替わります。ボタンの明暗も同時に反転し、ラベルは「絞り込み」が上へ抜けて、下から「閉じる」が入ってきます。
アイコンは SVG の線と円でできていて、その座標を「ハンドルの滑り」「ハンドルの大きさ」「線の寄り」「× への回り」といった値から、毎回計算して描いています。ハンドルの滑りを開閉とは別のトゥイーンに分けたので、× に変わっている途中でカーソルが離れても、どちらの動きも途切れません。滑りは0.42秒の power3.inOut で、ハンドルごとに0.06秒ずらしました。行きと帰りで同じ形のイージングだから、途中で離れても無理なく引き返せます。
線を中央へ寄せて回す動きは、3本線のメニューのアイコンと変わりません。違うのは、その前にハンドルを線の中央へ片付ける段があることです。戻りを × への動きの逆再生にすると、回っていた線が先に跳ねてから戻るので、どうも落ち着きません。線の回転と寄りは行き過ぎないイージングで先に戻し、最後にハンドルだけを中央から back.out(2) で弾ませることにしました。ハンドルの中はボタンの塗りと同じ色にしてあり、線がハンドルの後ろを通っているように見えます。
開閉は aria-expanded で伝え、aria-controls で絞り込みのパネルを指しています。ラベルは2つの語を同じ位置に重ねて上下に送っているので、見えていない方には aria-hidden を付け、ボタンの名前が見えている語だけになるようにしました。キーボードでフォーカスが来たときもハンドルは滑ります。Esc でパネルを閉じると、フォーカスはボタンへ戻ります。
コード
<!-- 押すと絞り込みのパネルを開閉するボタン。開いている間はアイコンが × に、ラベルが「閉じる」になる -->
<button class="filter-toggle" type="button" aria-expanded="false" aria-controls="filter-panel">
<!-- 線とハンドルの位置は script.js が描く。ここに書いた位置は、スクリプトが動く前の見た目 -->
<svg class="filter-toggle__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<line x1="4" y1="6" x2="20" y2="6" />
<line x1="4" y1="12" x2="20" y2="12" />
<line x1="4" y1="18" x2="20" y2="18" />
<circle cx="15" cy="6" r="2.25" />
<circle cx="8.5" cy="12" r="2.25" />
<circle cx="13" cy="18" r="2.25" />
</svg>
<!-- 2 語を同じ位置に重ね、見えていない語は aria-hidden でボタンの名前から外す -->
<span class="filter-toggle__labels">
<span class="filter-toggle__label">絞り込み</span>
<span class="filter-toggle__label" aria-hidden="true">閉じる</span>
</span>
</button>
<div class="filter-panel" id="filter-panel" hidden>
<fieldset class="filter-panel__group">
<legend class="filter-panel__title">条件</legend>
<label class="filter-panel__option"><input type="checkbox" name="stock"> 在庫あり</label>
<label class="filter-panel__option"><input type="checkbox" name="free-shipping"> 送料無料</label>
<label class="filter-panel__option"><input type="checkbox" name="sale"> セール中</label>
</fieldset>
</div>
.filter-toggle {
--ink: #262626; /* 線・文字・枠線の色。開いている間はボタンの塗りになる */
--surface: #ffffff; /* ボタンの塗り。開いている間は線と文字の色になる */
--invert: 0; /* 明暗の反転(0〜1)。script.js が動かす */
--swap: 0; /* ラベルの入れ替え(0〜1)。script.js が動かす */
--face: color-mix(in srgb, var(--surface), var(--ink) calc(var(--invert) * 100%));
display: inline-flex;
align-items: center;
gap: 10px;
height: 48px;
padding: 0 28px 0 20px;
border: 0;
border-radius: 999px;
background: var(--face);
/* 枠線は inset の影で引き、ボタンの寸法に含めない */
box-shadow: inset 0 0 0 1px var(--ink);
color: color-mix(in srgb, var(--ink), var(--surface) calc(var(--invert) * 100%));
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.3;
white-space: nowrap;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.filter-toggle:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
/* 24 グリッドで線幅 2(24px で描けば 2px)。線端は丸 */
.filter-toggle__icon {
flex: none;
width: 24px;
height: 24px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
/* ハンドルの中はボタンの塗りと同じ色。線がハンドルの後ろを通っているように見える */
.filter-toggle__icon circle {
fill: var(--face);
}
/* 2 語を同じ位置に重ね、1 行ぶんの高さで切る。前の語は上へ抜け、次の語は下から入る */
.filter-toggle__labels {
display: grid;
overflow: hidden;
}
.filter-toggle__label {
grid-area: 1 / 1;
translate: 0 calc(var(--swap) * -100%);
}
.filter-toggle__label + .filter-toggle__label {
translate: 0 calc((1 - var(--swap)) * 100%);
}
.filter-panel {
width: min(100%, 320px);
box-sizing: border-box;
margin-top: 16px;
padding: 20px 24px;
border-radius: 4px;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(38, 38, 38, 0.12);
color: #262626;
}
.filter-panel[hidden] { display: none; }
.filter-panel__group { display: grid; gap: 12px; margin: 0; padding: 0; border: 0; }
.filter-panel__title { margin-bottom: 4px; padding: 0; font-size: 13px; font-weight: 700; letter-spacing: 0.06em; }
.filter-panel__option { display: flex; align-items: center; gap: 8px; font-size: 15px; letter-spacing: 0.04em; }
import { gsap } from "gsap";
// 24 グリッドの形。線は y 6・12・18(中心 x 12・半分の長さ 8)、× の 2 本は半分の長さ 7.78
const ROWS = [6, 12, 18];
const REST = [15, 8.5, 13]; // ハンドルの位置(x)
const SLID = [9, 15, 7.5]; // ホバーで滑った位置。上は左・中は右・下は左へ、互い違いに動かす
const KNOB_R = 2.25;
// 形の動き: [値, 行き先, 始まり, 長さ, ease]。× へは、ハンドルが中央へ縮んで消え、線が寄り、最後に 2 本が回って交わる
const TO_CLOSE = [
["knob", 0, 0, 0.22, "power2.in"],
["gather", 1, 0.1, 0.22, "power2.inOut"],
["cross", 1, 0.28, 0.4, "back.out(1.8)"],
["invert", 1, 0.08, 0.32, "power1.inOut"],
["swap", 1, 0.06, 0.4, "power3.inOut"],
];
// 戻りは逆再生にしない。逆にたどると、回っていた線が先に跳ねてから戻る。弾むのは最後に表示するハンドルだけ
const TO_OPEN = [
["cross", 0, 0, 0.24, "power2.in"],
["gather", 0, 0.18, 0.24, "power2.inOut"],
["knob", 1, 0.36, 0.42, "back.out(2)"],
["invert", 0, 0.05, 0.32, "power1.inOut"],
["swap", 0, 0.05, 0.4, "power3.inOut"],
];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lerp = (a, b, k) => a + (b - a) * k;
const clamp = gsap.utils.clamp(0, 1);
const slideEase = gsap.parseEase("power3.inOut");
document.querySelectorAll(".filter-toggle").forEach((button) => {
const panel = document.getElementById(button.getAttribute("aria-controls"));
const lines = button.querySelectorAll("line");
const knobs = button.querySelectorAll("circle");
const labels = button.querySelectorAll(".filter-toggle__label");
const state = { s0: 0, s1: 0, s2: 0, knob: 1, gather: 0, cross: 0, invert: 0, swap: 0 };
const setLine = (line, x, y, half, deg) => {
const a = (deg * Math.PI) / 180;
line.setAttribute("x1", x - half * Math.cos(a));
line.setAttribute("y1", y - half * Math.sin(a));
line.setAttribute("x2", x + half * Math.cos(a));
line.setAttribute("y2", y + half * Math.sin(a));
line.style.opacity = clamp(half * 2); // 短くなった線は、丸い線端の点が残るので消す
};
const draw = () => {
ROWS.forEach((row, i) => {
const y = i === 1 ? row : lerp(row, 12, state.gather);
if (i === 1) setLine(lines[i], 12, y, 8 * (1 - state.gather), 0);
else setLine(lines[i], 12, y, lerp(8, 7.78, state.cross), (i === 0 ? 45 : -45) * state.cross);
// ハンドルは滑った量の位置から、knob が 0 に向かうと線の中央へ寄りながら縮む
const x = lerp(REST[i], SLID[i], slideEase(clamp(state[`s${i}`])));
knobs[i].setAttribute("cx", lerp(12, x, state.knob));
knobs[i].setAttribute("cy", y);
knobs[i].setAttribute("r", Math.max(0, KNOB_R * state.knob));
knobs[i].style.opacity = clamp(state.knob * 5);
});
// 色とラベルの位置は CSS が、この 2 つの値から決める
button.style.setProperty("--invert", state.invert);
button.style.setProperty("--swap", state.swap);
};
let open = false;
let morph = null;
let slide = null;
const run = (steps) => {
morph?.kill();
morph = gsap.timeline({ onUpdate: draw });
for (const [key, to, at, duration, ease] of steps) morph.to(state, { [key]: to, duration, ease }, at);
if (reduceMotion) morph.progress(1); // 動きを減らす設定では、形を一度に替える
};
// ハンドルは開閉とは別のトゥイーン。上から 0.06 秒ずつずらし、行きも帰りも同じ速さで滑らせる
const slideTo = (to) => {
slide?.kill();
slide = gsap.timeline({ onUpdate: draw });
ROWS.forEach((_, i) => slide.to(state, { [`s${i}`]: to, duration: 0.42 * Math.abs(state[`s${i}`] - to), ease: "none" }, i * 0.06));
if (reduceMotion) slide.progress(1);
};
const toggle = (next) => {
open = next;
button.setAttribute("aria-expanded", String(open));
labels[0].setAttribute("aria-hidden", String(open)); // 見えている語だけをボタンの名前にする
labels[1].setAttribute("aria-hidden", String(!open));
if (panel) panel.hidden = !open;
run(open ? TO_CLOSE : TO_OPEN);
};
button.addEventListener("click", () => toggle(!open));
button.addEventListener("pointerenter", (e) => e.pointerType !== "touch" && slideTo(1));
button.addEventListener("pointerleave", () => slideTo(0));
// キーボードでフォーカスが来たときも、ホバーと同じようにハンドルを滑らせる
button.addEventListener("focus", () => button.matches(":focus-visible") && slideTo(1));
button.addEventListener("blur", () => slideTo(0));
// パネルの中やボタンで Esc を押すと閉じ、フォーカスをボタンへ戻す
[button, panel].forEach((el) => el?.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || !open) return;
toggle(false);
button.focus();
}));
draw();
});