ビジュアル
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を更新し、requestAnimationFrameでcurrentX / currentYへなめらかに追従させることで慣性っぽい動きになります。
各レイヤーはtranslate3d(current * speed, depth)で移動し、perspective+preserve-3dで立体感を出しています。
クリック選択では、選ばれたカードだけscale()で拡大し、他カードはopacity / blur / grayscaleで背景化します。さらに全画面オーバーレイを出して「背景クリックで閉じる」導線を作っています。
- 立体表現:
.kumonosu-containerのperspective+各要素の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が実値です) - 空間の密度:
layerConfigsのcount(枚数)とrange(散りばめ範囲) - 奥行きの厚み:カード内
posZのランダム幅(* 400の部分) - 視差の強弱:各レイヤーの
data-speed(近いほど大きく、遠いほど小さく) - 選択時の演出:ぼかし量(
blur(8px))、暗さ(opacity: 0.05)、白黒(grayscale(1))
注意点
カードが合計190枚なので、端末やブラウザによってはblurや大量のtransformで重くなることがあります。動作が厳しい場合は、枚数を減らす/選択時のblurを弱める/影を軽くするのが効きます。
また、クリックとドラッグの誤判定は起きやすいので、操作感を詰めるならdragThresholdを調整したり、モバイル向けにtouch操作(ドラッグ移動・タップ選択)を追加すると安定します。