デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
商品ページの色の選択で、丸い色見本に重ねると、左の商品画像がその色の写真へ切り替わり、「カラー」の横の色の名前が送られて入れ替わります。右の見本へは写真が右から・名前が下から、左の見本へは写真が左から・名前が上から入るので、並びのどちらへ動いたかが伝わります。見本を押すと、外に浮いていた輪がその見本の周りへ締まって選んだ印になり、前の見本の輪はほどけて消えます。並びから離れると、写真と名前は選んだ色へ戻ります。
写真は色ごとに撮った <img> を重ね、次の写真だけを 0.5 秒の power2.inOut で透明から出しながら、20px ずらした位置から 0.6 秒の power3.out で滑らせます。前の写真は下に不透明のまま残し、次の写真が出きってから隠します。2 枚を同時に薄めると、途中で後ろの地が透けて写真が暗く沈むためです。出きる前に別の見本へ移ったときは、下の写真と上の写真のうち多く見えているほうを残し、下の写真へ戻るときは上下を入れ替えて同じ見え方から続けるので、見本をなぞっても写真がちらつきません。
写真を重ねる前に decode() で読み込みと展開が済むのを待ちます。読み込み途中の写真を出すと、空の枠や上から描かれていく途中の写真が一瞬見えるためです。ページを開いた時点でほかの色の写真も展開しておくので、ふつうは待たずに切り替わります。名前と輪は写真を待たずにすぐ動かし、押した手応えを先に返します。
輪は --ring を 0 から 1 へ動かし、見本とのすきま(浮いているとき 12px、締まると 4px)と線の濃さを CSS が作ります。締まるのは 0.35 秒の power3.out、ほどけるのは 0.25 秒の power2.in で、選んだほうを長く見せます。名前は、前の名前が 0.16 秒で抜けきってから次の名前を 0.28 秒で入れ、2 つの名前が重ならないようにします。
土台は本物の radio で、見えなくした input を label で包みます。クリックでも矢印キーでも change が届き、フォームでそのまま送れます。写真の alt には色の名前を入れ、隠している写真は読み上げから外します。「カラー」の横に出す名前は aria-hidden にします。重ねただけで名前が読まれると、選んだ色と取り違えるためです。重ねて先に見せるのはマウスのときだけで、指で触れたときは押して選んだときに切り替えます。服や鞄、食器、家具など、色違いのある商品のページに向いていて、色を足すときは見本と写真を 1 組ずつ足すだけで済みます。このデモでは、見本に重ねると色を先に見せ、押すと選べます(矢印キーは、コードのほうの本物の radio が受けます)。動きを減らす設定では、写真を滑らせず、ほぼ一瞬で切り替えます。
コード
<!-- 色は本物の radio。見本に重ねると商品画像がその色の写真に切り替わり、押すと選ぶ(矢印キーでも選べる) -->
<!-- 写真は色ごとに重ね、data-color を radio の value に合わせる。最初に見せる写真に is-current を付ける -->
<section class="product">
<div class="product__photos">
<img class="product__photo" data-color="haizakura" src="/img/mug-haizakura.jpg" alt="美濃焼のマグカップ(灰桜)" width="1200" height="1200">
<img class="product__photo is-current" data-color="suna" src="/img/mug-suna.jpg" alt="美濃焼のマグカップ(砂色)" width="1200" height="1200">
<img class="product__photo" data-color="rikyunezumi" src="/img/mug-rikyunezumi.jpg" alt="美濃焼のマグカップ(利休鼠)" width="1200" height="1200">
<img class="product__photo" data-color="fujinezumi" src="/img/mug-fujinezumi.jpg" alt="美濃焼のマグカップ(藤鼠)" width="1200" height="1200">
<img class="product__photo" data-color="nibiiro" src="/img/mug-nibiiro.jpg" alt="美濃焼のマグカップ(鈍色)" width="1200" height="1200">
</div>
<div class="product__info">
<p class="product__label">TABLEWARE</p>
<h2 class="product__name">美濃焼のマグカップ</h2>
<p class="product__price">¥2,420<small>(税込)</small></p>
<p class="product__text">ぽってりとした、毎日使いやすい形です。<br>容量350ml・電子レンジと食洗機に対応。</p>
<fieldset class="color">
<!-- 色の名前は見た目だけ(読み上げは、選んだ radio の名前が伝える) -->
<legend class="color__legend">カラー<span class="color__mask" aria-hidden="true"><span class="color__name">砂色</span></span></legend>
<div class="color__list">
<label class="swatch" style="--c: #d29b97"><input type="radio" name="color" value="haizakura"><span class="swatch__dot"></span><span class="sr-only">灰桜</span></label>
<label class="swatch" style="--c: #d2b999"><input type="radio" name="color" value="suna" checked><span class="swatch__dot"></span><span class="sr-only">砂色</span></label>
<label class="swatch" style="--c: #a5c0a3"><input type="radio" name="color" value="rikyunezumi"><span class="swatch__dot"></span><span class="sr-only">利休鼠</span></label>
<label class="swatch" style="--c: #b0a5ca"><input type="radio" name="color" value="fujinezumi"><span class="swatch__dot"></span><span class="sr-only">藤鼠</span></label>
<label class="swatch" style="--c: #788b97"><input type="radio" name="color" value="nibiiro"><span class="swatch__dot"></span><span class="sr-only">鈍色</span></label>
</div>
</fieldset>
<button class="product__add" type="button">カートに入れる</button>
</div>
</section>
.product {
--bg: #111111;
--surface: #262626; /* 写真の枠(読み込む前に見える地) */
--ink: #eeece6; /* 文字・選んだ輪 */
--mute-small: #a8a6a0; /* 小さい補足 */
--shift: 20px; /* 次の写真が滑り込むときに、ずらしておく量 */
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: center;
gap: 64px;
padding: 64px 24px;
background: var(--bg);
color: var(--ink);
}
/* 色ごとの写真を重ねる。見せる 1 枚(と切り替えの間の 2 枚)だけを JS が見えるようにする */
.product__photos { position: relative; aspect-ratio: 1; overflow: hidden; border-radius: 4px; background: var(--surface); }
.product__photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; visibility: hidden; }
.product__photo.is-current { visibility: visible; }
.product__label { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute-small); }
.product__name { margin: 16px 0 0; font-size: clamp(28px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.product__price { margin: 20px 0 0; font-size: 24px; font-weight: 700; letter-spacing: 0.02em; font-variant-numeric: tabular-nums; }
.product__price small { margin-left: 4px; font-size: 13px; font-weight: 400; color: var(--mute-small); }
.product__text { margin: 16px 0 0; font-size: 14px; line-height: 1.8; letter-spacing: 0.04em; }
.color { min-width: 0; margin: 32px 0 0; padding: 0; border: 0; }
.color__legend { display: flex; align-items: center; gap: 8px; padding: 0; font-size: 13px; font-weight: 600; letter-spacing: 0.06em; color: var(--mute-small); }
.color__mask { display: block; height: 24px; overflow: hidden; }
.color__name { display: block; font-size: 15px; font-weight: 700; line-height: 24px; letter-spacing: 0.04em; white-space: nowrap; color: var(--ink); }
/* 輪が浮いたときの分だけ余白を取り、並びの外で切れないようにする */
.color__list { display: flex; flex-wrap: wrap; gap: 28px; margin: 4px 0 0 -16px; padding: 16px; }
/* 本物の radio は見えなくするだけ(display: none にすると、キーボードで選べなくなる) */
.swatch input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.swatch { --ring: 0; position: relative; flex: none; width: 40px; height: 40px; cursor: pointer; }
.swatch__dot { position: absolute; inset: 0; border-radius: 50%; background: var(--c); transition: inset 0.2s; }
.swatch:hover .swatch__dot { inset: -2px; }
/* 選んだ印の輪。--ring が 0 のとき外に浮いて透明、1 で見本の 4px 外まで締まって濃くなる(JS が動かす) */
.swatch::before {
content: "";
position: absolute;
inset: calc(-6px - (1 - var(--ring)) * 8px);
border: 2px solid color-mix(in srgb, var(--ink) calc(var(--ring) * 100%), transparent);
border-radius: 50%;
pointer-events: none;
}
/* キーボードの焦点は、選んだ輪と見分けがつくよう、見本の下の短い線で示す */
.swatch:has(:focus-visible)::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: -20px; height: 2px; background: var(--ink); }
.product__add {
margin-top: 28px;
height: 48px;
padding: 0 32px;
border: 0;
border-radius: 999px;
background: var(--ink);
color: var(--bg);
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.product__add:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
@media (max-width: 760px) {
.product { grid-template-columns: minmax(0, 1fr); gap: 32px; }
}
/* 狭い画面では見本の間を詰めて、5 色を 1 列に収める */
@media (max-width: 480px) {
.color__list { gap: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.swatch__dot { transition: none; }
}
import { gsap } from "gsap";
// 動きを減らす設定では、写真を滑らせず、どの動きもほぼ一瞬にする(長さ 0 にしないのは、並べたトゥイーンの順序を崩さないため)
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const speed = reduce ? 0.02 : 1;
document.querySelectorAll(".product").forEach((product) => {
const inputs = [...product.querySelectorAll(".swatch input")];
const swatches = inputs.map((input) => input.closest(".swatch"));
const photos = inputs.map((input) => product.querySelector(`.product__photo[data-color="${input.value}"]`));
const list = product.querySelector(".color__list");
const name = product.querySelector(".color__name");
// 名前は見本の中の文字(読み上げ用)、ずらす量は CSS の --shift から読む。JS に名前や寸法を書かない
const nameOf = (i) => swatches[i].querySelector(".sr-only").textContent;
const shift = reduce ? 0 : parseFloat(getComputedStyle(product).getPropertyValue("--shift"));
let selected = Math.max(0, inputs.findIndex((input) => input.checked));
let shown = selected;
let top = selected; // いちばん上に出ている(出てくる途中の)写真
let under = -1; // その下に不透明のまま残している前の写真
let fade, nameSwap;
// 前の写真を下に不透明のまま残し、次の写真だけを透明から出す(2 枚を同時に薄めると、途中で地が透けて暗く沈む)
const crossfade = (i, dir) => {
if (i === top) return;
const a = fade ? gsap.getProperty(photos[top], "opacity") : 1;
// 下に残すのは、多く見えているほう。戻る先が下の写真なら上と入れ替え、同じ見え方から続ける(なぞってもちらつかない)
const back = i === under || a >= 0.5 || under < 0 ? top : under;
const from = i === under ? 1 - a : 0;
fade?.kill();
photos.forEach((photo, k) => k !== i && k !== back && gsap.set(photo, { autoAlpha: 0, x: 0, zIndex: 0 }));
gsap.set(photos[back], { autoAlpha: 1, zIndex: 1 });
const x = i === under ? gsap.getProperty(photos[i], "x") : dir * shift;
fade = gsap.timeline({
onComplete: () => {
gsap.set(photos[back], { autoAlpha: 0, x: 0 }); // 隠れた写真を読み上げから外す
fade = null;
under = -1;
},
})
.fromTo(photos[i], { autoAlpha: from, zIndex: 2 }, { autoAlpha: 1, duration: 0.5 * (1 - from) * speed, ease: "power2.inOut" }, 0)
.fromTo(photos[i], { x }, { x: 0, duration: 0.6 * speed, ease: "power3.out" }, 0)
.to(photos[back], { x: 0, duration: 0.6 * speed, ease: "power3.out" }, 0);
under = back;
top = i;
};
// 名前はすぐ送り、写真は読み込みと展開が済んでから重ねる(読み込み途中の写真を出すと、空の枠や描きかけが一瞬見える)
const show = (i) => {
if (i === shown) return;
const dir = Math.sign(i - shown);
shown = i;
nameSwap?.kill();
nameSwap = gsap.timeline()
.to(name, { yPercent: -100 * dir, duration: 0.16 * speed, ease: "power2.in" })
.call(() => (name.textContent = nameOf(i))) // 前の名前が抜けきってから替える(2 つの名前が重ならないように)
.fromTo(name, { yPercent: 100 * dir }, { yPercent: 0, duration: 0.28 * speed, ease: "power3.out" });
photos[i].decode().catch(() => {}).then(() => shown === i && crossfade(i, dir));
};
// 選んだ輪は外から見本の周りへ締まり、前の輪はほどけて消える
const ring = (i, on) => gsap.to(swatches[i], {
"--ring": on ? 1 : 0,
duration: (on ? 0.35 : 0.25) * speed,
ease: on ? "power3.out" : "power2.in",
overwrite: true,
});
inputs.forEach((input, i) => {
// クリックでも矢印キーでも、選ばれた radio から change が届く
input.addEventListener("change", () => {
ring(selected, false);
ring(i, true);
selected = i;
show(i);
});
// 重ねた色を先に見せる(マウスのときだけ。指で触れたときは、押したときに選んで映す)
swatches[i].addEventListener("pointerenter", (event) => {
if (event.pointerType === "mouse") show(i);
});
});
// 見本の並びから離れたら、選んだ色へ戻す(見本の間のすきまでは戻さない)
list.addEventListener("pointerleave", () => show(selected));
// 最初の状態: 動かさずに、選んでいる色の写真・名前・輪を置く。ほかの色の写真も先に展開しておく
photos.forEach((photo, k) => gsap.set(photo, { autoAlpha: Number(k === selected), zIndex: Number(k === selected) }));
photos.forEach((photo) => photo.decode().catch(() => {}));
swatches[selected].style.setProperty("--ring", "1");
name.textContent = nameOf(selected);
});