CSSとJSで作る、ドラッグで3D空間を見渡せるギャラリー

ビジュアル

CSSとJSで作る、ドラッグで3D空間を見渡せるギャラリー

投稿日2026/03/01

更新日2026/2/26

写真をただ並べるだけではなく、「空間の中を探す体験」に変えると一気に印象が変わります。
このサンプルは、奥行きの違うレイヤーにカードを配置し、ドラッグで視点を動かすことでパララックス(視差)を作っています。気になるカードはクリックでズームしてフォーカスできるので、ギャラリー兼インタラクティブな作品展示としても使いやすい構成です

Preview プレビュー

Code コード

<div id="kumonosu-close-overlay"></div>
<div class="kumonosu-container" id="kumonosu-container">
	<div class="kumonosu-scene" id="kumonosu-scene">
		<!-- レイヤー:さらに密集感を高めるため枚数を増加 -->
		<div class="kumonosu-layer" id="kumonosu-layer-far-2" data-depth="-1200" data-speed="0.2"></div>
		<div class="kumonosu-layer" id="kumonosu-layer-far-1" data-depth="-600" data-speed="0.5"></div>
		<div class="kumonosu-layer" id="kumonosu-layer-mid" data-depth="0" data-speed="1.0"></div>
		<div class="kumonosu-layer" id="kumonosu-layer-near-1" data-depth="400" data-speed="1.8"></div>
		<div class="kumonosu-layer" id="kumonosu-layer-near-2" data-depth="700" data-speed="2.8"></div>
	</div>
</div>
:root {
	--kumonosu-bg-color: #000;
	--kumonosu-card-w: 110px;
	/* 密集度を上げるため少し小さく */
	--kumonosu-card-h: 150px;
	--kumonosu-drag-sensitivity: 2.8;
	--kumonosu-perspective: 1000px;
}
body, html {
	margin: 0;
	padding: 0;
	width: 100%;
	height: 100%;
	background-color: var(--kumonosu-bg-color);
	overflow: hidden;
	user-select: none;
	font-family: sans-serif;
	box-sizing: border-box;
}
.kumonosu-container {
	width: 100vw;
	height: 100vh;
	perspective: var(--kumonosu-perspective);
	display: flex;
	align-items: center;
	justify-content: center;
	cursor: grab;
}
.kumonosu-container:active {
	cursor: grabbing;
}
.kumonosu-scene {
	position: relative;
	transform-style: preserve-3d;
	will-change: transform;
}
.kumonosu-layer {
	position: absolute;
	top: 0;
	left: 0;
	transform-style: preserve-3d;
	will-change: transform;
}
.kumonosu-card {
	position: absolute;
	width: var(--kumonosu-card-w);
	height: var(--kumonosu-card-h);
	/* 中央基準配置 */
	margin-left: calc(var(--kumonosu-card-w) / -2);
	margin-top: calc(var(--kumonosu-card-h) / -2);
	background: #111;
	border-radius: 4px;
	box-shadow: 0 8px 25px rgba(0, 0, 0, 0.7);
	overflow: hidden;
	background-size: cover;
	background-position: center;
	border: 1px solid rgba(255, 255, 255, 0.05);
	cursor: pointer;
	transition: opacity 0.5s ease, filter 0.5s ease, transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
	will-change: transform, opacity;
}
/* 選択時の演出 */
.kumonosu-scene.kumonosu-has-selection .kumonosu-card:not(.kumonosu-is-selected) {
	opacity: 0.05;
	filter: blur(10px) grayscale(1);
	pointer-events: none;
}
.kumonosu-card.kumonosu-is-selected {
	z-index: 5000;
	box-shadow: 0 0 100px rgba(255, 255, 255, 0.4);
	border: 1px solid rgba(255, 255, 255, 0.8);
}
#kumonosu-close-overlay {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	z-index: 1000;
	display: none;
}
#kumonosu-close-overlay.kumonosu-active {
	display: block;
}
const kumonosuScene = document.getElementById('kumonosu-scene');
const kumonosuLayers = document.querySelectorAll('.kumonosu-layer');
const kumonosuContainer = document.getElementById('kumonosu-container');
const kumonosuCloseOverlay = document.getElementById('kumonosu-close-overlay');
let isDragging = false;
let isSelected = false;
let startMouseX, startMouseY;
let startTargetX, startTargetY;
let currentX = 0,
	currentY = 0;
let targetX = 0,
	targetY = 0;
let dragMoved = false;
const dragThreshold = 5;
const sensitivity = 2.8;
const perspective = 1000;
const layerConfigs = [{
	id: 'kumonosu-layer-far-2',
	count: 70,
	range: 6000
}, {
	id: 'kumonosu-layer-far-1',
	count: 60,
	range: 5000
}, {
	id: 'kumonosu-layer-mid',
	count: 50,
	range: 4000
}, {
	id: 'kumonosu-layer-near-1',
	count: 40,
	range: 3000
}, {
	id: 'kumonosu-layer-near-2',
	count: 30,
	range: 2500
}];
layerConfigs.forEach(config => {
	const layerEl = document.getElementById(config.id);
	const layerDepth = parseFloat(layerEl.getAttribute('data-depth'));
	const speed = parseFloat(layerEl.getAttribute('data-speed'));
	for (let i = 0; i < config.count; i++) {
		const card = document.createElement('div');
		card.className = 'kumonosu-card';
		const posX = (Math.random() - 0.5) * config.range;
		const posY = (Math.random() - 0.5) * config.range;
		const posZ = (Math.random() - 0.5) * 300; // レイヤー内分散
		card.style.transform = `translate3d(${posX}px, ${posY}px, ${posZ}px)`;
		const seed = `${config.id}-${i}`;
		card.style.backgroundImage = `url('https://picsum.photos/seed/${seed}/400/550')`;
		card.dataset.x = posX;
		card.dataset.y = posY;
		card.dataset.z = posZ;
		card.dataset.layerDepth = layerDepth;
		card.dataset.speed = speed;
		card.addEventListener('click', (e) => {
			if (dragMoved || isSelected) return;
			e.stopPropagation();
			kumonosuSelectCard(card);
		});
		layerEl.appendChild(card);
	}
});

function kumonosuSelectCard(card) {
	isSelected = true;
	card.classList.add('kumonosu-is-selected');
	kumonosuScene.classList.add('kumonosu-has-selection');
	kumonosuCloseOverlay.classList.add('kumonosu-active');
	const speed = parseFloat(card.dataset.speed);
	targetX = -parseFloat(card.dataset.x) / speed;
	targetY = -parseFloat(card.dataset.y) / speed;
	const x = card.dataset.x;
	const y = card.dataset.y;
	const z = card.dataset.z;
	const lDepth = parseFloat(card.dataset.layerDepth);
	// --- 重要:3D遠近法を考慮した倍率計算 ---
	const totalZ = parseFloat(z) + lDepth;
	// 遠近法による見かけのスケール(手前ほど大きく、奥ほど小さい)
	const perspectiveFactor = perspective / (perspective - totalZ);
	// 画面の70%サイズを目標にする(余白を考慮して0.7)
	const targetVisualW = window.innerWidth * 0.7;
	const targetVisualH = window.innerHeight * 0.7;
	const cardW = 110; // CSSと同じ
	const cardH = 150; // CSSと同じ
	// 目標の「画面上の大きさ」にするための、純粋な2D倍率を計算
	const targetScale2D = Math.min(targetVisualW / cardW, targetVisualH / cardH);
	// 3Dの歪みを打ち消して、最終的にtargetScale2Dの大きさに見えるように調整
	const finalScale = targetScale2D / perspectiveFactor;
	card.style.transform = `translate3d(${x}px, ${y}px, ${z}px) scale(${finalScale})`;
}

function kumonosuDeselect() {
	const selectedCard = document.querySelector('.kumonosu-card.kumonosu-is-selected');
	if (selectedCard) {
		const x = selectedCard.dataset.x;
		const y = selectedCard.dataset.y;
		const z = selectedCard.dataset.z;
		selectedCard.style.transform = `translate3d(${x}px, ${y}px, ${z}px) scale(1)`;
		selectedCard.classList.remove('kumonosu-is-selected');
	}
	kumonosuScene.classList.remove('kumonosu-has-selection');
	kumonosuCloseOverlay.classList.remove('kumonosu-active');
	isSelected = false;
}
kumonosuCloseOverlay.addEventListener('click', kumonosuDeselect);
window.addEventListener('resize', () => {
	if (isSelected) kumonosuDeselect();
});
kumonosuContainer.addEventListener('mousedown', (e) => {
	if (isSelected) return;
	isDragging = true;
	dragMoved = false;
	startMouseX = e.pageX;
	startMouseY = e.pageY;
	startTargetX = targetX;
	startTargetY = targetY;
});
window.addEventListener('mousemove', (e) => {
	if (!isDragging) return;
	const dx = (e.pageX - startMouseX) * sensitivity;
	const dy = (e.pageY - startMouseY) * sensitivity;
	if (Math.abs(dx) > dragThreshold || Math.abs(dy) > dragThreshold) dragMoved = true;
	targetX = startTargetX + dx;
	targetY = startTargetY + dy;
});
window.addEventListener('mouseup', () => {
	isDragging = false;
});

function kumonosuUpdate() {
	currentX += (targetX - currentX) * 0.08;
	currentY += (targetY - currentY) * 0.08;
	kumonosuLayers.forEach(layer => {
		const speed = parseFloat(layer.getAttribute('data-speed'));
		const depth = layer.getAttribute('data-depth');
		layer.style.transform = `translate3d(${currentX * speed}px, ${currentY * speed}px, ${depth}px)`;
	});
	requestAnimationFrame(kumonosuUpdate);
}
kumonosuUpdate();

Explanation 詳しい説明

仕様

5つのレイヤーにカードをランダム配置し、各レイヤーにdata-speedを持たせて視差の強さを変えています。

ドラッグでtargetX / targetYを更新し、requestAnimationFramecurrentX / currentYへなめらかに追従させることで慣性っぽい動きになります。

各レイヤーはtranslate3d(current * speed, depth)で移動し、perspectivepreserve-3dで立体感を出しています。

クリック選択では、選ばれたカードだけscale()で拡大し、他カードはopacity / blur / grayscaleで背景化します。さらに全画面オーバーレイを出して「背景クリックで閉じる」導線を作っています。

  • 立体表現:.kumonosu-containerperspective+各要素のtranslate3d
  • 視差:レイヤーごとのdata-speedで移動量を変える
  • 慣性:current += (target - current) * 0.08で追従
  • 選択ズーム:クリックでscale(zoom)+他カードを暗く/ぼかす
  • 閉じる操作:オーバーレイクリックで選択解除

カスタム

見た目と体験は、変数と設定値を触るだけで大きく変えられます。まずは「枚数・空間の広さ・ズーム感・慣性」を調整すると狙いに寄せやすいです。

  • カードサイズ:--kumonosu-card-w / --kumonosu-card-h
  • ズーム倍率:--kumonosu-zoom-scale(※今はJS側のzoom = 3.5も合わせて変更すると統一できます)
  • ドラッグ感度:--kumonosu-drag-sensitivity(※今はJS側const sensitivity = 2.8が実値です)
  • 空間の密度:layerConfigscount(枚数)とrange(散りばめ範囲)
  • 奥行きの厚み:カード内posZのランダム幅(* 400の部分)
  • 視差の強弱:各レイヤーのdata-speed(近いほど大きく、遠いほど小さく)
  • 選択時の演出:ぼかし量(blur(8px))、暗さ(opacity: 0.05)、白黒(grayscale(1)

注意点

カードが合計190枚なので、端末やブラウザによってはblurや大量のtransformで重くなることがあります。動作が厳しい場合は、枚数を減らす/選択時のblurを弱める/影を軽くするのが効きます。

また、クリックとドラッグの誤判定は起きやすいので、操作感を詰めるならdragThresholdを調整したり、モバイル向けにtouch操作(ドラッグ移動・タップ選択)を追加すると安定します。