CSSとJSで作る、スクロール連動で回転する3Dキューブギャラリー

スクロール

CSSとJSで作る、スクロール連動で回転する3Dキューブギャラリー

投稿日2026/03/30

更新日2026/3/29

スクロールに合わせて画面中央のビジュアルが変化する演出は、ポートフォリオやブランドサイトで強い印象を与えるUIのひとつです。

このサンプルでは、CSSの3D表現とJavaScriptによるスクロール制御を組み合わせ、立方体(キューブ)が回転しながらシーンを切り替えるギャラリーを実装しています。
通常のスライダーとは異なり、「ページ全体が体験型UIになる」のが大きな特徴です。

視覚的インパクトを持たせたいLPや作品展示サイトに最適な構成になっています。

Preview プレビュー

Code コード

<div id="kumonosu-scene">
	<div id="kumonosu-cube">
		<div class="kumonosu-face" data-face="top" data-i="0"><span class="kumonosu-face-ph">TOP</span></div>
		<div class="kumonosu-face" data-face="front" data-i="1"><span class="kumonosu-face-ph">FRONT</span></div>
		<div class="kumonosu-face" data-face="right" data-i="2"><span class="kumonosu-face-ph">RIGHT</span></div>
		<div class="kumonosu-face" data-face="back" data-i="3"><span class="kumonosu-face-ph">BACK</span></div>
		<div class="kumonosu-face" data-face="left" data-i="4"><span class="kumonosu-face-ph">LEFT</span></div>
		<div class="kumonosu-face" data-face="bottom" data-i="5"><span class="kumonosu-face-ph">BOTTOM</span></div>
	</div>
</div>
<div id="kumonosu-hud">
	<div id="kumonosu-hud-pct">000%</div>
	<div class="kumonosu-progress-bar">
		<div class="kumonosu-progress-fill" id="kumonosu-prog-fill"></div>
	</div>
	<div class="kumonosu-scene-label" id="kumonosu-scene-name">MOUNTAIN</div>
</div>
<div id="kumonosu-scene-strip"></div>
<div id="kumonosu-face-caption">
	<div id="kumonosu-face-caption-num">01</div>
	<div id="kumonosu-face-caption-name">MOUNTAIN</div>
</div>
<div id="kumonosu-scroll-container">
	<section id="kumonosu-s0" class="kumonosu-section">
		<div class="kumonosu-text-card">
			<div class="kumonosu-tag">01 — Alpine Peak</div>
			<h1 class="kumonosu-h1">SILENT<br>HEIGHTS</h1>
			<p class="kumonosu-body-text">雲海を眼下に、静寂が支配する山の頂。そびえ立つ岩肌が太陽の光を浴びて輝きます。</p>
			<div class="kumonosu-cta-row"><a class="kumonosu-cta" href="#kumonosu-s1">Explore</a></div>
		</div>
	</section>
	<section id="kumonosu-s1" class="kumonosu-section">
		<div class="kumonosu-text-card kumonosu-right">
			<div class="kumonosu-tag">02 — Green Sanctuary</div>
			<h2 class="kumonosu-h2">DEEP<br>WOODS</h2>
			<p class="kumonosu-body-text">密に生い茂る木々の隙間から差し込む木漏れ日。生命の息吹を感じる豊かな森の光景。</p>
			<div class="kumonosu-cta-row">
				<a class="kumonosu-cta-back" href="#kumonosu-s0">Back</a>
				<a class="kumonosu-cta" href="#kumonosu-s2">Turn</a>
			</div>
		</div>
	</section>
	<section id="kumonosu-s2" class="kumonosu-section">
		<div class="kumonosu-text-card">
			<div class="kumonosu-tag">03 — Earthy Path</div>
			<h2 class="kumonosu-h2">OPEN<br>VALLEY</h2>
			<p class="kumonosu-body-text">広大な大地へと続く一本の道。風が通り抜ける草原の中を、歩みを進めます。</p>
			<div class="kumonosu-cta-row">
				<a class="kumonosu-cta-back" href="#kumonosu-s1">Back</a>
				<a class="kumonosu-cta" href="#kumonosu-s3">Turn</a>
			</div>
		</div>
	</section>
	<section id="kumonosu-s3" class="kumonosu-section">
		<div class="kumonosu-text-card kumonosu-right">
			<div class="kumonosu-tag">04 — Still Water</div>
			<h2 class="kumonosu-h2">CRYSTAL<br>LAKE</h2>
			<p class="kumonosu-body-text">鏡のように穏やかな湖面。周囲の山々を映し出すその姿は、時の流れを止めたかのよう。</p>
			<div class="kumonosu-cta-row">
				<a class="kumonosu-cta-back" href="#kumonosu-s2">Back</a>
				<a class="kumonosu-cta" href="#kumonosu-s4">Turn</a>
			</div>
		</div>
	</section>
	<section id="kumonosu-s4" class="kumonosu-section">
		<div class="kumonosu-text-card">
			<div class="kumonosu-tag">05 — Cosmic Night</div>
			<h2 class="kumonosu-h2">STARRY<br>GALAXY</h2>
			<p class="kumonosu-body-text">夜空に広がる無数の星々と銀河。地球から遠く離れた宇宙の深淵に思いを馳せます。</p>
			<div class="kumonosu-cta-row">
				<a class="kumonosu-cta-back" href="#kumonosu-s3">Back</a>
				<a class="kumonosu-cta" href="#kumonosu-s5">Turn</a>
			</div>
		</div>
	</section>
	<section id="kumonosu-s5" class="kumonosu-section">
		<div class="kumonosu-text-card kumonosu-right">
			<div class="kumonosu-tag">06 — Water's Edge</div>
			<h2 class="kumonosu-h2">SUNSET<br>SHORE</h2>
			<p class="kumonosu-body-text">夕暮れ時に染まる波打ち際。旅の終わりを告げる穏やかな波の音に耳を傾けて。</p>
			<div class="kumonosu-cta-row">
				<a class="kumonosu-cta-back" href="#kumonosu-s4">Back</a>
				<a class="kumonosu-cta" href="#kumonosu-s0">Restart</a>
			</div>
		</div>
	</section>
</div>
<div id="kumonosu-credit">
	<a href="#">KUMONOSU GALLERY</a>
</div>
@import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Mono:wght@300;400&display=swap");
@layer reset, tokens, base, layout, cube, ui, cards, reveal, responsive;
@layer reset {
	*, *::before, *::after {
		box-sizing: border-box;
		margin: 0;
		padding: 0;
	}
}
@layer tokens {
	:root {
		color-scheme: dark;
		--kumonosu-bg: #1c1814;
		--kumonosu-fg: #ede8df;
		--kumonosu-muted: #8a7b6e;
		--kumonosu-accent: #d4a84b;
		--kumonosu-font-display: "Bebas Neue", sans-serif;
		--kumonosu-font-mono: "DM Mono", monospace;
		--kumonosu-hairline: 0.0625rem;
		--kumonosu-ui-inset: 2rem;
		--kumonosu-card-bg: rgba(28, 24, 20, 0.82);
		--kumonosu-card-border: rgba(212, 168, 75, 0.2);
		--kumonosu-nav-x: calc(var(--kumonosu-ui-inset) + 0.125rem);
		--kumonosu-reveal-offset: 0.625rem;
		--kumonosu-reveal-duration: 0.5s;
		--kumonosu-z-ui: 10;
	}
}
@layer base {
	body {
		background: var(--kumonosu-bg);
		color: var(--kumonosu-fg);
		font-family: var(--kumonosu-font-mono);
		overflow-x: hidden;
	}
}
@layer layout {
	#kumonosu-scene {
		position: fixed;
		inset: 0;
		z-index: 0;
		display: flex;
		align-items: center;
		justify-content: center;
		perspective: 1100px;
		pointer-events: none;
	}
	#kumonosu-scroll-container {
		position: relative;
		z-index: 1;
	}
	.kumonosu-section {
		min-height: 100vh;
		display: flex;
		align-items: center;
		padding: 6rem calc(5rem + var(--kumonosu-ui-inset)) 6rem 5rem;
	}
}
@layer cube {
	#kumonosu-cube {
		--s: min(74vw, 74vh, 560px);
		width: var(--s);
		height: var(--s);
		position: relative;
		transform-style: preserve-3d;
		transform: rotateX(90deg) rotateY(0deg);
		will-change: transform;
	}
	.kumonosu-face {
		position: absolute;
		inset: 0;
		overflow: hidden;
		backface-visibility: hidden;
		background: repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.02) 0, rgba(255, 255, 255, 0.02) 1px, transparent 1px, transparent 48px),
			repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.02) 0, rgba(255, 255, 255, 0.02) 1px, transparent 1px, transparent 48px),
			#14100d;
	}
	.kumonosu-face img {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}
	.kumonosu-face-ph {
		position: absolute;
		bottom: 1.5rem;
		left: 1.75rem;
		font-family: var(--kumonosu-font-display);
		font-size: clamp(2rem, 8vw, 5rem);
		color: rgba(255, 255, 255, 0.06);
		pointer-events: none;
	}
	.kumonosu-face[data-face="front"] {
		transform: translateZ(calc(var(--s) / 2));
	}
	.kumonosu-face[data-face="back"] {
		transform: rotateY(180deg) translateZ(calc(var(--s) / 2));
	}
	.kumonosu-face[data-face="right"] {
		transform: rotateY(90deg) translateZ(calc(var(--s) / 2));
	}
	.kumonosu-face[data-face="left"] {
		transform: rotateY(-90deg) translateZ(calc(var(--s) / 2));
	}
	.kumonosu-face[data-face="top"] {
		transform: rotateX(-90deg) translateZ(calc(var(--s) / 2));
	}
	.kumonosu-face[data-face="bottom"] {
		transform: rotateX(90deg) translateZ(calc(var(--s) / 2));
	}
}
@layer ui {
	#kumonosu-hud {
		position: fixed;
		top: var(--kumonosu-ui-inset);
		right: var(--kumonosu-ui-inset);
		z-index: var(--kumonosu-z-ui);
		text-align: right;
		font-size: 0.65rem;
		letter-spacing: 0.15em;
		color: var(--kumonosu-muted);
		text-transform: uppercase;
	}
	.kumonosu-progress-bar {
		width: 7.5rem;
		height: var(--kumonosu-hairline);
		background: var(--kumonosu-muted);
		margin-top: 0.5rem;
		margin-left: auto;
		position: relative;
		overflow: hidden;
	}
	.kumonosu-progress-fill {
		position: absolute;
		inset: 0;
		width: 0%;
		background: var(--kumonosu-accent);
		transition: width 0.1s linear;
	}
	#kumonosu-scene-strip {
		position: fixed;
		left: var(--kumonosu-nav-x);
		top: 50%;
		translate: -50% -50%;
		z-index: var(--kumonosu-z-ui);
		display: flex;
		flex-direction: column;
		gap: 0.75rem;
	}
	.kumonosu-scene-dot {
		width: 0.25rem;
		height: 0.25rem;
		border-radius: 50%;
		background: var(--kumonosu-muted);
		transition: 0.3s;
		cursor: pointer;
	}
	.kumonosu-scene-dot.kumonosu-active {
		background: var(--kumonosu-accent);
		scale: 1.8;
	}
	#kumonosu-face-caption {
		position: fixed;
		bottom: var(--kumonosu-ui-inset);
		left: 50%;
		translate: -50% 0;
		z-index: var(--kumonosu-z-ui);
		text-align: center;
		pointer-events: none;
	}
	#kumonosu-face-caption-num {
		font-size: 0.58rem;
		color: var(--kumonosu-accent);
		letter-spacing: 0.28em;
	}
	#kumonosu-face-caption-name {
		font-family: var(--kumonosu-font-display);
		font-size: clamp(1.8rem, 5vw, 3.5rem);
		color: var(--kumonosu-muted);
		opacity: 0.5;
	}
	#kumonosu-credit {
		position: fixed;
		right: var(--kumonosu-ui-inset);
		top: 50%;
		transform: translateY(-50%) rotate(-90deg);
		transform-origin: right center;
		font-size: 0.65rem;
		letter-spacing: 0.15em;
		text-transform: uppercase;
	}
	#kumonosu-credit a {
		color: var(--kumonosu-muted);
		text-decoration: none;
	}
}
@layer cards {
	.kumonosu-text-card {
		max-width: 23.75rem;
		padding: 2.25rem 2rem;
		background: var(--kumonosu-card-bg);
		border-left: var(--kumonosu-hairline) solid var(--kumonosu-card-border);
		backdrop-filter: blur(6px);
		transition: 0.3s;
	}
	.kumonosu-text-card.kumonosu-right {
		margin-left: auto;
		border-left: none;
		border-right: var(--kumonosu-hairline) solid var(--kumonosu-card-border);
		text-align: right;
	}
	.kumonosu-tag {
		font-size: 0.6rem;
		letter-spacing: 0.25em;
		color: var(--kumonosu-accent);
		margin-bottom: 1.1rem;
		text-transform: uppercase;
	}
	.kumonosu-h1, .kumonosu-h2 {
		font-family: var(--kumonosu-font-display);
		line-height: 0.92;
		font-weight: 400;
	}
	.kumonosu-h1 {
		font-size: clamp(3rem, 8vw, 6.5rem);
	}
	.kumonosu-h2 {
		font-size: clamp(2.2rem, 5vw, 4rem);
	}
	.kumonosu-body-text {
		font-size: 0.78rem;
		line-height: 1.8;
		color: rgba(255, 255, 255, 0.55);
		margin-top: 1.25rem;
	}
	.kumonosu-cta-row {
		display: flex;
		gap: 0.75rem;
		margin-top: 1.75rem;
	}
	.kumonosu-text-card.kumonosu-right .kumonosu-cta-row {
		justify-content: flex-end;
	}
	.kumonosu-cta, .kumonosu-cta-back {
		padding: 0.6rem 1.25rem;
		border: var(--kumonosu-hairline) solid var(--kumonosu-accent);
		color: var(--kumonosu-accent);
		font-size: 0.62rem;
		text-transform: uppercase;
		text-decoration: none;
		display: inline-flex;
		align-items: center;
		gap: 0.6rem;
		transition: 0.2s;
	}
	.kumonosu-cta:hover {
		background: var(--kumonosu-accent);
		color: var(--kumonosu-bg);
	}
	.kumonosu-cta-back {
		border-color: rgba(128, 128, 128, 0.4);
		color: var(--kumonosu-muted);
	}
}
@layer reveal {
	:is(.kumonosu-tag, .kumonosu-h1, .kumonosu-h2, .kumonosu-body-text, .kumonosu-cta, .kumonosu-cta-back) {
		opacity: 0;
		translate: 0 var(--kumonosu-reveal-offset);
		transition: 0.5s ease;
	}
	:is(.kumonosu-tag, .kumonosu-h1, .kumonosu-h2, .kumonosu-body-text, .kumonosu-cta, .kumonosu-cta-back).kumonosu-visible {
		opacity: 1;
		translate: 0 0;
	}
}
@layer responsive {
	@media (max-width: 56.25em) {
		#kumonosu-scene-strip {
			display: none;
		}
		.kumonosu-section {
			padding: 0 1.5rem 3.5rem;
			align-items: flex-end;
		}
		.kumonosu-text-card {
			max-width: 100%;
		}
	}
}
const IMAGE_SRCS = [
  "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=800&q=80",
  "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=800&q=80",
  "https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?auto=format&fit=crop&w=800&q=80",
  "https://images.unsplash.com/photo-1472214103451-9374bd1c798e?auto=format&fit=crop&w=800&q=80",
  "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa?auto=format&fit=crop&w=800&q=80",
  "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=800&q=80"
];

const FACE_NAMES = ["MOUNTAIN", "FOREST", "VALLEY", "LAKE", "GALAXY", "SHORE"];
const N = IMAGE_SRCS.length;

const STOPS = [
  { rx: 90, ry: 0 }, { rx: 0, ry: 0 }, { rx: 0, ry: -90 },
  { rx: 0, ry: -180 }, { rx: 0, ry: -270 }, { rx: -90, ry: -360 }
];

const dom = {
  cube: document.getElementById("kumonosu-cube"),
  faces: [...document.querySelectorAll(".kumonosu-face")],
  scrollEl: document.getElementById("kumonosu-scroll-container"),
  strip: document.getElementById("kumonosu-scene-strip"),
  hudPct: document.getElementById("kumonosu-hud-pct"),
  progFill: document.getElementById("kumonosu-prog-fill"),
  sceneName: document.getElementById("kumonosu-scene-name"),
  captionNum: document.getElementById("kumonosu-face-caption-num"),
  captionName: document.getElementById("kumonosu-face-caption-name")
};

IMAGE_SRCS.forEach((_, i) => {
  const a = document.createElement("a");
  a.href = `#kumonosu-s${i}`;
  a.className = "kumonosu-scene-dot" + (i === 0 ? " kumonosu-active" : "");
  dom.strip.appendChild(a);
});
const sceneDots = [...document.querySelectorAll(".kumonosu-scene-dot")];

async function setFaceImage(faceIdx, imgIdx) {
  const face = dom.faces[faceIdx];
  if (face.querySelector('img')) return;
  const img = new Image();
  img.src = IMAGE_SRCS[imgIdx];
  face.appendChild(img);
}

const faceMap = [5, 1, 2, 3, 4, 0];
faceMap.forEach((faceIdx, i) => setFaceImage(faceIdx, i));

let lastIdx = -1;
const updateHUD = (s) => {
  const p = Math.round(s * 100);
  dom.hudPct.textContent = String(p).padStart(3, "0") + "%";
  dom.progFill.style.width = `${p}%`;
  
  const si = Math.max(0, Math.min(Math.floor(s * (N - 0.001)), N - 1));
  if (si !== lastIdx) {
    lastIdx = si;
    dom.sceneName.textContent = FACE_NAMES[si];
    dom.captionNum.textContent = String(si + 1).padStart(2, "0");
    dom.captionName.textContent = FACE_NAMES[si];
    sceneDots.forEach((d, i) => d.classList.toggle("kumonosu-active", i === si));
  }
};

const easeIO = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;

const setCubeTransform = (s) => {
  const t = s * (N - 1);
  const i = Math.min(Math.floor(t), N - 2);
  const f = easeIO(t - i);
  const a = STOPS[i], b = STOPS[i + 1];
  const rx = a.rx + (b.rx - a.rx) * f;
  const ry = a.ry + (b.ry - a.ry) * f;
  dom.cube.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`;
};

let tgt = 0, smooth = 0;
window.addEventListener("scroll", () => {
  const max = document.documentElement.scrollHeight - window.innerHeight;
  tgt = max > 0 ? window.scrollY / max : 0;
}, { passive: true });

const io = new IntersectionObserver((entries) => {
  entries.forEach(e => { if (e.isIntersecting) e.target.classList.add("kumonosu-visible"); });
}, { threshold: 0.1 });
document.querySelectorAll(".kumonosu-text-card > *").forEach(el => io.observe(el));

const frame = () => {
  smooth += (tgt - smooth) * 0.1;
  updateHUD(smooth);
  setCubeTransform(smooth);
  requestAnimationFrame(frame);
};
requestAnimationFrame(frame);

document.querySelectorAll('a[href^="#kumonosu-s"]').forEach(anchor => {
  anchor.addEventListener('click', function (e) {
    e.preventDefault();
    const target = document.querySelector(this.getAttribute('href'));
    if (target) target.scrollIntoView({ behavior: 'smooth' });
  });
});

Explanation 詳しい説明

基本構造

  • 固定配置された3Dキューブ(背景レイヤー)
  • スクロール可能なセクション(前景コンテンツ)
  • スクロール量 → キューブ回転角度へ変換
Scroll量 → 進行率 → 回転角度 → 表示面切替

3Dキューブ構成

  • 6面で構成された立方体
  • 各面に画像を配置
  • CSS 3D transform を使用

主な設定:

  • perspective
  • transform-style: preserve-3d
  • translateZ()
  • rotateX() / rotateY()

スクロールに応じて次の面へ滑らかに回転します。

スライダー的挙動(このコードの特徴)

一般的なスライダーではなく:

✅ スクロール駆動型スライダー
✅ ページ自体がタイムラインになる構造

特徴:

  • スクロール = スライド移動
  • セクションごとに1面表示
  • イージング補間あり(easeInOut)

HUD(UIオーバーレイ)

画面固定UIとして以下を表示:

  • 進行率(%表示)
  • プログレスバー
  • 現在のシーン名
  • ナビゲーションドット
  • フェイスキャプション

スクロール状態を視覚的に把握できます。

Revealアニメーション

テキスト要素は:

  • 下方向からフェードイン
  • Intersection的な表示制御
  • クラス追加でアニメーション発火
opacity + translate

による軽量演出です。

カスタム方法

① 画像の変更

const IMAGE_SRCS = [
"image1.jpg",
"image2.jpg"
];

配列を変更するだけで差し替え可能。

② シーン名変更

const FACE_NAMES = ["MOUNTAIN", "FOREST"];

HUD・キャプションへ自動反映されます。

③ キューブサイズ調整

--s: min(74vw, 74vh, 560px);

数値を変更すると全体スケールが変わります。

④ 回転演出の調整

const STOPS = [
{ rx: 90, ry: 0 }
];

ここを変更すると:

  • 回転方向
  • 見せたい面
  • カメラ演出

を自由に設計可能。

⑤ カラーテーマ変更

:root {
--kumonosu-accent:
--kumonosu-bg:
}

トークン設計されているためテーマ変更が容易です。

注意点

1. GPU負荷

3D transform を常時使用するため:

  • 低スペック端末では負荷増加
  • モバイルで軽量化検討推奨

2. position: fixed 依存

キューブは固定レイヤーのため:

  • 親要素に transform を付けない
  • overflow 制御に注意

3. スクロール高さ依存UI

セクション数変更時は:

  • セクション数
  • 画像数
  • STOPS配列

を一致させる必要があります。

4. Safari対応

3D描画はSafariで差が出る場合あり:

  • backface-visibility: hidden は必須
  • perspective値を大きめにすると安定

5. アクセシビリティ

スクロール主体UIのため:

  • キーボード操作補助
  • reduced-motion 対応

を追加すると実運用向きになります。

このUIが向いている用途

  • ポートフォリオサイト
  • ブランドLP
  • 写真ギャラリー
  • コンセプトページ
  • プロダクトストーリー紹介