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

アイコン

絞り込みアイコンのハンドルが滑り、押すと × に組み替わる

デモ

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

仕様

用途
アイコン
動作
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 でパネルを閉じると、フォーカスはボタンへ戻ります。

コード

HTML
<!-- 押すと絞り込みのパネルを開閉するボタン。開いている間はアイコンが × に、ラベルが「閉じる」になる -->
<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>

CSS
.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; }

JavaScript
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();
});

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

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