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

表示切り替えのアイコンで、格子の四角が一覧の行へ組み替わる

デモ

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

仕様

用途
アイコン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

格子の 4 つの四角と、一覧の 3 本の行は、どちらも同じ 4 つの rect です。形を差し替えず、左上の座標と幅・高さを、格子の値と行の値のあいだで補って書きます。四角は 4 つ、行は 3 本なので、上の 2 つは同じ行へ向かい、右上の 1 つだけが行になりきる手前で消えて、2 つが 1 本に合わさって見えます。

四角ごとに 0.06 秒ずらして動かすので、伸びて組み替わる流れが読めます。戻るときは、ずらす順を逆にします。ずらしを 0 にすると 4 つが同時に伸びて、どれがどの行になったのかがわかりません。組み替えは 1 つあたり 0.36 秒(power3.inOut)です。

選ばれている側は、丸の地と線の明暗を反転して見せます。反転は 0.55 秒と、組み替えより長くかけます。線の色を移すのは、丸の濃さが半分を越えてからです。地と線を同時に移すと、途中で明るさが並んでアイコンが一瞬消えます。四角の位置と大きさは、書くたびに画面の整数 px へ丸めます。半端な座標のまっすぐな辺は、描き直しのたびに端の画素が変わります。

選ばれているかどうかは aria-pressed が持ち、ボタンの名前は切り替えても変えません。並べ方そのものは、一覧の側の data-view を書き換えて CSS に任せます。アイコンの動きと一覧のレイアウトを別々に直せるので、カードと行、地図と一覧のような切り替えにも同じ形で使えます。

コード

HTML
<!-- 表示の名前は data-* に持ち、今の名前は role="status" で読み上げにも届ける -->
<div class="view" data-grid="グリッド表示" data-list="リスト表示">
  <!-- 名前は変えず、選ばれているかは aria-pressed が持つ。一覧の側は aria-controls で指す -->
  <button class="view__button" type="button" aria-pressed="false" aria-controls="gallery" aria-label="一覧で表示">
    <svg class="view__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      <rect class="view__cell" x="3.5" y="3.5" width="7" height="7" rx="1.5" />
      <rect class="view__cell" x="13.5" y="3.5" width="7" height="7" rx="1.5" />
      <rect class="view__cell" x="3.5" y="13.5" width="7" height="7" rx="1.5" />
      <rect class="view__cell" x="13.5" y="13.5" width="7" height="7" rx="1.5" />
    </svg>
  </button>
  <p class="view__name" role="status">グリッド表示</p>
</div>

<!-- 並べ方は data-view で CSS に渡す。中身はサイトのものに書き換える -->
<ul id="gallery" class="gallery" data-view="grid">
  <li>リネンのクッション</li>
  <li>木のスツール</li>
  <li>陶器のマグカップ</li>
  <li>麻のトートバッグ</li>
</ul>

CSS
.view {
  --ink: #262626;   /* 線と、選ばれているときの丸の地 */
  --paper: #f2f1ed; /* 地の色。選ばれているときの線の色になる */
  --size: 24px;     /* アイコンの大きさ */
  display: flex;
  align-items: center;
  gap: 12px;
  color: var(--ink);
}

/* アイコンだけのボタン(1px の線の丸) */
.view__button {
  width: 48px;
  height: 48px;
  padding: 0;
  border: 1px solid var(--ink);
  border-radius: 50%;
  background-color: transparent;
  display: grid;
  place-items: center;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

.view__icon {
  width: var(--size);
  height: var(--size);
  display: block;
}

/* 24 グリッドで線幅 2(24px で描けば 2px)。角は丸 */
.view__cell {
  fill: none;
  stroke: var(--ink);
  stroke-width: 2;
  stroke-linejoin: round;
}

.view__button:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

.view__name {
  margin: 0;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -0.01em;
}

/* 並べ方は data-view だけで切り替える(JS は属性を書くだけ) */
.gallery {
  max-width: 640px;
  margin: 24px 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 8px;
}

.gallery[data-view="grid"] {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.gallery li {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 48px;
  padding: 12px 16px;
  background: #fff;
  box-shadow: inset 0 0 0 1px rgba(38, 38, 38, 0.12);
  font-size: 14px;
}

/* 商品の画像の代わりの色面。実際は img に置き換える */
.gallery li::before {
  content: "";
  flex: none;
  width: 40px;
  aspect-ratio: 1;
  border-radius: 2px;
  background: var(--photo);
}

.gallery li:nth-child(4n + 1) { --photo: #a28767; }
.gallery li:nth-child(4n + 2) { --photo: #739374; }
.gallery li:nth-child(4n + 3) { --photo: #688297; }
.gallery li:nth-child(4n) { --photo: #b27771; }

.gallery[data-view="grid"] li {
  flex-direction: column;
  align-items: stretch;
}

.gallery[data-view="grid"] li::before { width: 100%; aspect-ratio: 4 / 3; }

JavaScript
import { gsap } from "gsap";

const SQ = 7; // 格子の四角の一辺(24 のグリッド)
const ROW = { w: 17, h: 3 }; // 行の大きさ
// 4 つの四角の「格子の左上」と「行の左上」。上の 2 つは同じ行へ向かい、右上だけが最後に消える
const SHAPES = [
  { grid: [3.5, 3.5], row: [3.5, 4.5], fades: false },
  { grid: [13.5, 3.5], row: [3.5, 4.5], fades: true },
  { grid: [3.5, 13.5], row: [3.5, 10.5], fades: false },
  { grid: [13.5, 13.5], row: [3.5, 16.5], fades: false },
];
const MORPH = 0.36; // 1 つが組み替わる時間
const STEP = 0.06; // 四角ごとにずらす時間
const TURN = 0.55; // 明暗を反転する時間
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const f = (v) => Math.round(v * 1000) / 1000;

document.querySelectorAll(".view").forEach((box) => {
  const button = box.querySelector(".view__button");
  const cells = [...box.querySelectorAll(".view__cell")];
  const name = box.querySelector(".view__name");
  const gallery = document.getElementById(button.getAttribute("aria-controls"));
  // 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
  const css = getComputedStyle(box);
  const ink = css.getPropertyValue("--ink").trim();
  const paper = css.getPropertyValue("--paper").trim();
  const inkRGB = gsap.utils.splitColor(ink); // 丸の地は同じ色の濃さだけを動かすので、RGB に分ける

  // 四角ごとの進み具合(0 = 格子、1 = 行)と、明暗の反転の具合
  const parts = SHAPES.map(() => ({ p: 0 }));
  const view = { sel: 0 };
  const draw = () => {
    // 線の色は、丸が十分に濃くなってから移す(同時に移すと、途中で地と線が同じ明るさになって図形が消える)
    const stroke = gsap.utils.interpolate(ink, paper, gsap.utils.clamp(0, 1, (view.sel - 0.5) / 0.4));
    button.style.backgroundColor = `rgba(${inkRGB.join(", ")}, ${f(view.sel)})`;
    cells.forEach((cell, i) => {
      const { grid, row, fades } = SHAPES[i];
      const p = parts[i].p;
      cell.setAttribute("x", String(f(grid[0] + (row[0] - grid[0]) * p)));
      cell.setAttribute("y", String(f(grid[1] + (row[1] - grid[1]) * p)));
      cell.setAttribute("width", String(f(SQ + (ROW.w - SQ) * p)));
      cell.setAttribute("height", String(f(SQ + (ROW.h - SQ) * p)));
      cell.style.stroke = stroke;
      // 重なる 1 つは、行の形になりきるまでに消える(2 つが 1 本に合わさって見える)
      if (fades) cell.style.opacity = String(f(1 - gsap.utils.clamp(0, 1, (p - 0.62) / 0.38)));
    });
  };
  draw();

  let anim = null;
  button.addEventListener("click", () => {
    const list = button.getAttribute("aria-pressed") !== "true";
    button.setAttribute("aria-pressed", String(list));
    name.textContent = box.dataset[list ? "list" : "grid"];
    if (gallery) gallery.dataset.view = list ? "list" : "grid";
    anim?.kill(); // 途中で押し直しても、今の形から次の形へつなぐ
    anim = gsap
      .timeline({ onUpdate: draw })
      .to(parts, { p: list ? 1 : 0, duration: MORPH, ease: "power3.inOut", stagger: { each: STEP, from: list ? "start" : "end" } }, 0)
      .to(view, { sel: list ? 1 : 0, duration: TURN, ease: "power2.inOut" }, 0.1);
    // 動きを減らす設定では、ほぼ一瞬で切り替える
    if (reduceMotion) anim.progress(1);
  });
});

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

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