CSSとJSだけで作る、3Dフリップがめくれるスクロールアニメーション

スクロール

CSSとJSだけで作る、3Dフリップがめくれるスクロールアニメーション

投稿日2026/04/13

更新日2026/4/8

ファーストビューで強い印象を与えたいとき、静止画像だけでは物足りない場面があります。
このサンプルでは、1枚の画像をグリッド状のタイルに分割し、それぞれが3D回転しながら次のビジュアルへ切り替わるヒーローアニメーションを実装しています。

CSSの3D表現とGSAPのタイムライン制御を組み合わせることで、複雑に見える演出をシンプルな構造で実現しています。スクロールやスマホのスワイプにも対応しており、PC・SPそれぞれに最適化されたレスポンシブ仕様になっています。

Preview プレビュー

Code コード

<div class="kumonosu-hero" id="kumonosu-hero-section">
	<div class="kumonosu-grid" id="kumonosu-grid-container"></div>
</div>
:root {
	--kumonosu-bg-dark: #000;
}
body, html {
	margin: 0;
	padding: 0;
	background-color: var(--kumonosu-bg-dark);
	overflow: hidden;
	width: 100%;
	height: 100%;
}
.kumonosu-hero {
	position: fixed;
	top: 0;
	left: 0;
	width: 100vw;
	height: 100vh;
	z-index: 10;
	perspective: 1200px;
}
.kumonosu-grid {
	display: grid;
	width: 100vw;
	height: 100vh;
	gap: 3px;
	transform-style: preserve-3d;
	transform: translateZ(-20px);
	will-change: gap, transform;
}
.kumonosu-panel {
	position: relative;
	transform-style: preserve-3d;
	will-change: transform;
}
.kumonosu-panel-item {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	backface-visibility: hidden;
	border-radius: 10px;
	background-repeat: no-repeat;
	will-change: border-radius;
}
.kumonosu-panel-item[data-panel-item="front"] {
	transform: rotateX(0deg);
}
.kumonosu-panel-item[data-panel-item="back"] {
	transform: rotateX(180deg);
}
const KUMONOSU_CONFIG = {
	pc: {
		front: 'https://kumonosu.net/wp-content/uploads/2026/04/260408a.jpg.webp',
		back: 'https://kumonosu.net/wp-content/uploads/2026/04/260408b.jpg.webp',
		cols: 6,
		rows: 6,
		aspect: 1536 / 975
	},
	sp: {
		front: 'https://kumonosu.net/wp-content/uploads/2026/04/260408a_sp.jpg.webp',
		back: 'https://kumonosu.net/wp-content/uploads/2026/04/260408b_sp.jpg.webp',
		cols: 3,
		rows: 6,
		aspect: 750 / 1334
	},
	breakpoint: 768
};
const gridContainer = document.getElementById('kumonosu-grid-container');
let kumonosuPanels = [];
let kumonosuFlipTl;
let kumonosuIsFlipped = false;

function initKumonosuApp() {
	if (kumonosuFlipTl) kumonosuFlipTl.kill();
	gridContainer.innerHTML = '';
	kumonosuPanels = [];
	const vw = window.innerWidth;
	const vh = window.innerHeight;
	const isMobile = vw <= KUMONOSU_CONFIG.breakpoint;
	const mode = isMobile ? KUMONOSU_CONFIG.sp : KUMONOSU_CONFIG.pc;
	gridContainer.style.gridTemplateColumns = `repeat(${mode.cols}, 1fr)`;
	gridContainer.style.gridTemplateRows = `repeat(${mode.rows}, 1fr)`;
	const imgAspect = mode.aspect;
	const winAspect = vw / vh;
	let bgSizeW, bgSizeH;
	if (winAspect > imgAspect) {
		bgSizeW = vw;
		bgSizeH = vw / imgAspect;
	} else {
		bgSizeW = vh * imgAspect;
		bgSizeH = vh;
	}
	const offsetX = (vw - bgSizeW) / 2;
	const offsetY = (vh - bgSizeH) / 2;
	for (let i = 0; i < mode.rows * mode.cols; i++) {
		const panel = document.createElement('div');
		panel.className = 'kumonosu-panel';
		const colIndex = i % mode.cols;
		const rowIndex = Math.floor(i / mode.cols);
		// 各タイルのピクセルサイズと座標を精密に計算
		const tileW = vw / mode.cols;
		const tileH = vh / mode.rows;
		const posX = colIndex * tileW;
		const posY = rowIndex * tileH;
		const bgX = -(posX - offsetX);
		const bgY = -(posY - offsetY);
		panel.innerHTML = `
                    <div class="kumonosu-panel-item" data-panel-item="front" 
                         style="background-image: url(${mode.front}); background-size: ${bgSizeW}px ${bgSizeH}px; background-position: ${bgX}px ${bgY}px;"></div>
                    <div class="kumonosu-panel-item" data-panel-item="back" 
                         style="background-image: url(${mode.back}); background-size: ${bgSizeW}px ${bgSizeH}px; background-position: ${bgX}px ${bgY}px;"></div>
                `;
		gridContainer.appendChild(panel);
		kumonosuPanels.push(panel);
	}
	buildKumonosuTimeline();
	if (kumonosuIsFlipped) {
		gsap.set(kumonosuPanels, {
			rotateX: -180,
			scale: 1.01
		}); // めくれた状態では少し拡大して隙間を埋める
		gsap.set(gridContainer, {
			gap: 0,
			z: 0
		});
		gsap.set(".kumonosu-panel-item", {
			borderRadius: 0
		});
		kumonosuFlipTl.progress(1);
	}
}

function buildKumonosuTimeline() {
	kumonosuFlipTl = gsap.timeline({
		paused: true,
		onComplete: () => {
			kumonosuIsFlipped = true;
		},
		onReverseComplete: () => {
			kumonosuIsFlipped = false;
		}
	});
	// 1. 回転アニメーション
	kumonosuFlipTl.to(kumonosuPanels, {
		duration: 1.3,
		rotateX: -180,
		ease: "elastic.out(0.8, 0.4)",
		stagger: {
			each: 0.006,
			from: "random"
		}
	}, 0);
	// 2. 隙間、奥行きの解消 + 【重要】スケールをわずかに大きくして隙間を埋める
	kumonosuFlipTl.to(gridContainer, {
		gap: 0,
		z: 0,
		duration: 0.2,
		ease: "power3.inOut"
	}, 0.4);
	// 3. タイル自体を1%大きくして、隣と重ねることで境界線の隙間を消す
	kumonosuFlipTl.to(kumonosuPanels, {
		scale: 1.01, // 1.01倍にすることで物理的な隙間を覆い隠す
		duration: 0.2
	}, 0.4);
	// 4. 角の丸みを消す
	kumonosuFlipTl.to(".kumonosu-panel-item", {
		borderRadius: 0,
		duration: 0.1
	}, 0.6);
}
initKumonosuApp();
let kumonosuResizeTimer;
window.addEventListener('resize', () => {
	clearTimeout(kumonosuResizeTimer);
	kumonosuResizeTimer = setTimeout(initKumonosuApp, 200);
});
const handleKumonosuWheel = (e) => {
	if (e.deltaY > 0 && !kumonosuIsFlipped) kumonosuFlipTl.play();
	else if (e.deltaY < 0 && kumonosuIsFlipped) kumonosuFlipTl.reverse();
};
window.addEventListener('wheel', handleKumonosuWheel, {
	passive: false
});
let kumonosuTouchStart = 0;
window.addEventListener('touchstart', (e) => {
	kumonosuTouchStart = e.touches[0].clientY;
}, {
	passive: true
});
window.addEventListener('touchmove', (e) => {
	const touchEnd = e.touches[0].clientY;
	const diff = kumonosuTouchStart - touchEnd;
	if (diff > 10 && !kumonosuIsFlipped) kumonosuFlipTl.play();
	else if (diff < -10 && kumonosuIsFlipped) kumonosuFlipTl.reverse();
}, {
	passive: false
});
https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js

Explanation 詳しい説明

基本構造

このUIは「ヒーローエリア」「グリッドコンテナ」「タイルパネル」の3階層で構成されています。

最上位の .kumonosu-hero に perspective を設定し、子要素の3D変形を有効化しています。内部の .kumonosu-grid は CSS Grid によって画面全体を分割し、その中に複数の .kumonosu-panel を動的生成しています。

各パネルは「表(front)」「裏(back)」の2枚を重ねた構造になっており、rotateX による180度回転で画像を切り替えています。backface-visibility を使用することで、裏面表示時の不自然な描画を防いでいます。

仕様

画像は1枚をそのまま表示しているのではなく、JavaScriptで画面サイズを取得し、グリッド分割された位置ごとに background-position を計算しています。これにより、タイルが分割されていても全体では1枚の画像として自然に見える仕組みになっています。

background-size: cover と同等の計算をJS側で行い、ウィンドウ比率と画像アスペクト比を比較してサイズを算出しています。この処理によって画面サイズが変わっても画像のトリミングが崩れません。

GSAPでは timeline を使用し、

・各タイルの rotateX 回転
・グリッドの gap 縮小
・角丸(border-radius)の除去

を時間差で制御しています。stagger を random 指定にすることで、均一ではない自然なめくれ演出を作っています。

スクロール(wheel)では下方向で再生、上方向で逆再生され、スマホでは touchmove の上下スワイプで同様の動作を行います。

カスタム

タイル数は KUMONOSU_CONFIG 内の cols と rows を変更するだけで調整可能です。分割数を増やすほど繊細な演出になり、減らすほどダイナミックな動きになります。

PCとSPで別画像を使用できるように設定が分離されており、それぞれに最適なアスペクト比を指定できます。ヒーロービジュアルをデバイスごとに最適化したい場合に有効です。

アニメーションの印象は以下で調整できます。

duration を変更するとフリップ速度が変化します。
elastic.out の数値を変更すると跳ね返りの強さを調整できます。
stagger の each 値を増やすと連続感が強くなります。

perspective の値を小さくすると立体感が強まり、大きくするとフラットな印象になります。

注意点

タイル数を増やしすぎるとDOM要素数が急増し、特にモバイル環境でパフォーマンスに影響が出る可能性があります。実運用では6×6前後がバランスの良い目安です。

3D transform はGPU処理になりますが、同時アニメーション数が多いため低スペック端末では負荷が高くなる場合があります。必要に応じて分割数やdurationを調整してください。

resize 時にはレイアウトを完全再構築しているため、頻繁なリサイズイベントを防ぐ目的で debounce 処理を入れています。この構造は削除しないよう注意してください。

また、ヒーローを position: fixed にしているため、通常のページスクロールと併用する場合は表示タイミングやスクロール制御を別途設計する必要があります。