デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- 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 に任せます。アイコンの動きと一覧のレイアウトを別々に直せるので、カードと行、地図と一覧のような切り替えにも同じ形で使えます。
コード
<!-- 表示の名前は 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>
.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; }
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);
});
});