デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
カードをすべて同じ位置に重ね、回す軸を画面の奥へ半径ぶん下げてから(transform-origin の 3 つ目の値を負の半径に)、1 枚ごとに 30° ずつ rotateY で回します。軸が奥にあるので、カードは円筒の周りに並び、正面のカードは奥行き 0 のまま等倍でくっきり描かれます。セクションを画面に固定しているあいだのスクロール量をそのまま回した量にし、画面の高さの 3 倍で 1 周します。
半径は、カードの幅と間を枚数ぶん並べた長さが円周になるように決めます(幅 208px・間 24px・12 枚なら約 443px)。並べる枠を 8° だけ見下ろすように傾けると、奥の列が上にずれて円筒らしく見えます。横を向くほど地の色の幕を濃くし(真横で 0.31、真後ろで 0.62)、正面から離れたカードを沈めて奥行きを出します。
カードは表と裏の 2 面にし、どちらも backface-visibility を hidden にして、裏は無地にします。表の面だけで裏を隠さないと、奥の列で写真と文字が裏返しに透けて見えます。また、円筒を包む要素に overflow を付けると、中の 3D が平らにつぶれます。はみ出しを隠すのは、画面に固定する外側の箱で行ってください。
カードをリンクにしたときは、Tab で奥のカードへ移ると、そのカードが正面に来る位置までスクロールさせます。裏を向いたカードは、見えないまま焦点だけが移ってしまうためです。正面のカードの名前と会期は下の欄に写し、何枚目かを点で示します。動きを減らす設定では回さず、横にスクロールできる列のまま見せます。
つまみは、枚数・カードの大きさ・見下ろす角度・奥行きの強さ(perspective)・1 周させるスクロール量です。枚数を減らすと半径が小さくなり、隣のカードの傾きが急になるので、8〜12 枚が目安です。展示やイベント、商品の一覧、チームの紹介に向いています。
コード
<section class="ring" aria-labelledby="ring-title">
<div class="ring__head">
<p class="ring__eyebrow">EXHIBITIONS</p>
<h2 class="ring__title" id="ring-title">12の展示室を、ひとめぐり。</h2>
</div>
<!-- スクリプトが無いときや動きを減らす設定では、横にスクロールできる列のまま。円筒に並べるのはスクリプト -->
<div class="ring__stage">
<ol class="ring__cards">
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/blue-hour/">
<img class="ring-card__image" src="/img/blue-hour.jpg" alt="" width="416" height="554">
<span class="ring-card__name">青の時間</span> <span class="ring-card__dates">3月12日〜5月30日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/paper-suns/">
<img class="ring-card__image" src="/img/paper-suns.jpg" alt="" width="416" height="554">
<span class="ring-card__name">紙の太陽</span> <span class="ring-card__dates">3月20日〜6月8日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/night-garden/">
<img class="ring-card__image" src="/img/night-garden.jpg" alt="" width="416" height="554">
<span class="ring-card__name">夜の庭</span> <span class="ring-card__dates">4月2日〜6月14日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/low-tide/">
<img class="ring-card__image" src="/img/low-tide.jpg" alt="" width="416" height="554">
<span class="ring-card__name">引き潮</span> <span class="ring-card__dates">4月9日〜7月1日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/soft-machines/">
<img class="ring-card__image" src="/img/soft-machines.jpg" alt="" width="416" height="554">
<span class="ring-card__name">やわらかな機械</span> <span class="ring-card__dates">4月18日〜7月12日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/sand-dunes/">
<img class="ring-card__image" src="/img/sand-dunes.jpg" alt="" width="416" height="554">
<span class="ring-card__name">砂の丘</span> <span class="ring-card__dates">5月1日〜7月20日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/glasshouse/">
<img class="ring-card__image" src="/img/glasshouse.jpg" alt="" width="416" height="554">
<span class="ring-card__name">温室</span> <span class="ring-card__dates">5月8日〜8月2日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/after-rain/">
<img class="ring-card__image" src="/img/after-rain.jpg" alt="" width="416" height="554">
<span class="ring-card__name">雨のあと</span> <span class="ring-card__dates">5月22日〜8月16日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/salt-stone/">
<img class="ring-card__image" src="/img/salt-stone.jpg" alt="" width="416" height="554">
<span class="ring-card__name">塩と石</span> <span class="ring-card__dates">6月3日〜8月30日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/tidal/">
<img class="ring-card__image" src="/img/tidal.jpg" alt="" width="416" height="554">
<span class="ring-card__name">潮騒</span> <span class="ring-card__dates">6月11日〜9月6日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/field-notes/">
<img class="ring-card__image" src="/img/field-notes.jpg" alt="" width="416" height="554">
<span class="ring-card__name">野の記録</span> <span class="ring-card__dates">6月19日〜9月20日</span>
</a></li>
<li class="ring-card"><a class="ring-card__link" href="/exhibitions/half-light/">
<img class="ring-card__image" src="/img/half-light.jpg" alt="" width="416" height="554">
<span class="ring-card__name">薄明かり</span> <span class="ring-card__dates">7月1日〜10月4日</span>
</a></li>
</ol>
</div>
<!-- 正面のカードの名前と会期、何枚目かの点。中身はカードの文字の写しなので、読み上げからは外す -->
<div class="ring__now" aria-hidden="true">
<div>
<p class="ring__label">開催中の展示</p>
<p class="ring__name"></p>
<p class="ring__dates"></p>
</div>
<div class="ring__dots"></div>
</div>
</section>
.ring {
--bg: #111111;
--surface: #262626;
--ink: #eeece6;
--mute: #a8a6a0;
--line: rgba(238, 236, 230, 0.14);
--on-image: #ffffff;
--on-image-mute: #dedcd6;
--card-width: clamp(144px, 29vh, 208px);
--card-height: calc(var(--card-width) * 4 / 3);
--gap: 24px;
--tilt: -8deg; /* 見下ろす角度 */
/* 半径は、カードの幅と間を枚数ぶん並べた長さが円周になるように(枚数はスクリプトが --count に入れる) */
--radius: calc((var(--card-width) + var(--gap)) * var(--count, 12) / 6.2832);
--gutter: clamp(16px, 7.5vw, 96px);
background: var(--bg);
color: var(--ink);
}
.ring__head { padding: 48px var(--gutter) 32px; text-align: center; }
.ring__eyebrow, .ring__label { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
.ring__label { letter-spacing: 0.06em; text-transform: none; }
.ring__title { margin: 16px 0 0; font-size: clamp(28px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: auto-phrase; }
/* 平らな列(スクリプトが無いとき・動きを減らす設定) */
.ring__cards {
display: flex; gap: var(--gap); margin: 0; padding: 0 var(--gutter) 48px;
overflow-x: auto; list-style: none; scroll-snap-type: x mandatory;
}
.ring-card { flex: none; width: var(--card-width); height: var(--card-height); scroll-snap-align: center; }
.ring-card__link {
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
justify-content: flex-end;
height: 100%;
box-sizing: border-box;
padding: 16px;
overflow: hidden;
border-radius: 4px;
color: var(--on-image);
text-decoration: none;
backface-visibility: hidden; /* 奥の列では表を隠し、裏の無地の面を見せる */
}
.ring-card__link:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.ring-card__image { position: absolute; inset: 0; z-index: -2; width: 100%; height: 100%; object-fit: cover; }
/* 名前の下に敷く地の色の幕と、横を向くほど濃くする暗幕(濃さはスクリプトが --shade に入れる) */
.ring-card__link::before { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(0deg, rgb(17 17 17 / 0.72), rgb(17 17 17 / 0) 42%); }
.ring-card__link::after { content: ""; position: absolute; inset: 0; background: var(--bg); opacity: var(--shade, 0); pointer-events: none; }
.ring-card__name { font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.ring-card__dates { margin-top: 4px; font-size: 13px; line-height: 1.4; letter-spacing: 0.04em; color: var(--on-image-mute); font-variant-numeric: tabular-nums; }
.ring__now { display: none; }
/* 円筒に並べるとき。画面に固定する高さにし、カードを同じ位置に重ねて、奥へ半径ぶんの軸で回す。
hidden ではなく clip にするのは、Tab で奥のカードに移ったとき、ブラウザに横へずらさせないため */
.ring.is-ring { position: relative; height: 100vh; overflow: clip; }
.is-ring .ring__head { position: absolute; top: 48px; left: 0; right: 0; padding-block: 0; }
.is-ring .ring__stage { position: absolute; inset: 0; perspective: 260vh; perspective-origin: 50% 47%; }
.is-ring .ring__cards {
position: absolute;
left: calc(50% - var(--card-width) / 2);
top: calc(47% - var(--card-height) / 2);
width: var(--card-width);
height: var(--card-height);
padding: 0;
overflow: visible; /* overflow を付けると、中の 3D が平らにつぶれる */
transform-style: preserve-3d;
transform-origin: 50% 50% calc(var(--radius) * -1);
transform: rotateX(var(--tilt));
}
.is-ring .ring-card { position: absolute; inset: 0; transform-style: preserve-3d; transform-origin: 50% 50% calc(var(--radius) * -1); }
/* 裏は無地(奥の列が鏡文字にならないように) */
.is-ring .ring-card::after { content: ""; position: absolute; inset: 0; border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); transform: rotateY(180deg); backface-visibility: hidden; }
.is-ring .ring__now {
position: absolute;
left: var(--gutter);
right: var(--gutter);
bottom: 48px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: 16px 32px;
}
.ring__name { margin: 8px 0 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.ring__dates { margin: 4px 0 0; font-size: 14px; line-height: 1.4; letter-spacing: 0.04em; color: var(--mute); font-variant-numeric: tabular-nums; }
.ring__dots { display: flex; gap: 8px; padding-bottom: 8px; }
.ring__dots span { width: 8px; height: 8px; border-radius: 50%; background: rgb(238 236 230 / 0.25); }
.ring__dots .is-current { background: var(--ink); }
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const TURN = "+=300%"; // 1 周させる間のスクロール量(画面の高さの 3 倍)
const SHADE = 0.62; // 真横を向いたカードに重ねる暗幕の濃さ
function turnRing(section) {
const cards = gsap.utils.toArray(".ring-card", section);
const name = section.querySelector(".ring__name");
const dates = section.querySelector(".ring__dates");
const dotBox = section.querySelector(".ring__dots");
const step = 360 / cards.length;
const dots = cards.map(() => dotBox.appendChild(document.createElement("span")));
section.style.setProperty("--count", cards.length);
section.classList.add("is-ring");
let front = -1;
// 回した量(turn、1 = 1 周)から、各カードの角度と暗幕、正面のカードの名前を描く
const draw = (turn) => {
cards.forEach((card, i) => {
const angle = i * step - turn * 360;
card.style.transform = `rotateY(${angle}deg)`;
card.style.setProperty("--shade", (SHADE * (1 - Math.cos((angle * Math.PI) / 180))) / 2);
});
const next = Math.round(turn * cards.length) % cards.length;
if (next === front) return;
front = next;
name.textContent = cards[next].querySelector(".ring-card__name").textContent;
dates.textContent = cards[next].querySelector(".ring-card__dates").textContent;
dots.forEach((dot, i) => dot.classList.toggle("is-current", i === next));
};
// セクションを画面に固定し、固定している間のスクロール量をそのまま回した量にする
const st = ScrollTrigger.create({
trigger: section,
start: "top top",
end: TURN,
pin: true,
onUpdate: (self) => draw(self.progress),
});
draw(st.progress);
// Tab で奥のカードに移ったら、そのカードが正面に来る位置までスクロールする
const reveal = (event) => {
const i = cards.indexOf(event.target.closest(".ring-card"));
if (i < 0 || !event.target.matches(":focus-visible")) return;
st.scroll(st.start + (i / cards.length) * (st.end - st.start));
};
section.addEventListener("focusin", reveal);
// 設定が変わって外すとき。固定は matchMedia が元に戻す
return () => {
section.removeEventListener("focusin", reveal);
section.classList.remove("is-ring");
cards.forEach((card) => {
card.style.removeProperty("transform");
card.style.removeProperty("--shade");
});
dots.forEach((dot) => dot.remove());
};
}
// 動きを減らす設定では回さず、横にスクロールできる列のまま見せる(設定を切り替えると付け外しされる)
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
const undo = [...document.querySelectorAll(".ring")].map(turnRing);
return () => undo.forEach((fn) => fn());
});