CSSとJSで作る、ドラッグやスクロールで自由に選べる没入型ギャラリー

アニメーション

CSSとJSで作る、ドラッグやスクロールで自由に選べる没入型ギャラリー

投稿日2026/03/05

更新日2026/3/4

一覧をただ並べるだけではなく、ユーザーがドラッグやスクロールで直感的に操作できる体験を作りたい。そんなときに使えるのが、中央にフォーカスが吸着する没入型ギャラリーです。

大きなキャンバスを自由に動かし、最も近いアイテムが自然に中央へ収まる構造。さらに背景には巨大な年号テキストがフェード表示され、視覚的な没入感も演出できます。この記事では、CSSとJavaScriptを組み合わせた実装の仕組みからカスタマイズ方法、注意点までをわかりやすく解説します。

Preview プレビュー

Code コード

<div id="kumonosu-main-container">
	<div class="kumonosu-loading-overlay" id="kumonosu-loader">
		<span class="kumonosu-serif italic text-3xl md:text-4xl">Loading...</span>
	</div>
	<div class="kumonosu-background-years">
		<div class="kumonosu-year-bg kumonosu-serif" id="kumonosu-year-display"></div>
	</div>
	<div id="kumonosu-viewport">
		<div id="kumonosu-canvas">
		</div>
	</div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@1,400;1,700&family=Inter:wght@300;400;600&display=swap');
:root {
	--bg-color: #a51d24;
	--text-color: #ffffff;
	--kumonosu-item-width: 400px;
	--kumonosu-bottle-height: 380px;
	--kumonosu-gap: 100px;
}
@media (max-width: 768px) {
	:root {
		--kumonosu-item-width: 200px;
		--kumonosu-bottle-height: 200px;
		--kumonosu-gap: 60px;
	}
}
body, html {
	margin: 0;
	padding: 0;
}
#kumonosu-main-container {
	width: 100vw;
	height: 100vh;
	overflow: hidden;
	background-color: var(--bg-color);
	color: var(--text-color);
	font-family: 'Inter', sans-serif;
	cursor: grab;
	touch-action: none;
	position: relative;
}
#kumonosu-main-container:active {
	cursor: grabbing;
}
.kumonosu-serif {
	font-family: 'Playfair Display', serif;
}
.kumonosu-background-years {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	display: flex;
	justify-content: center;
	align-items: center;
	pointer-events: none;
	z-index: 0;
}
.kumonosu-year-bg {
	font-size: 35vw;
	font-weight: 700;
	font-style: italic;
	opacity: 0;
	transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1), transform 1.2s cubic-bezier(0.4, 0, 0.2, 1);
	user-select: none;
	transform: scale(0.9);
}
.kumonosu-year-bg.kumonosu-visible {
	opacity: 0.1;
	transform: scale(1);
}
#kumonosu-viewport {
	width: 100%;
	height: 100%;
	overflow: hidden;
	position: relative;
	z-index: 10;
}
#kumonosu-canvas {
	position: absolute;
	display: grid;
	grid-template-columns: repeat(5, var(--kumonosu-item-width));
	gap: var(--kumonosu-gap);
	padding: 2000px;
	will-change: transform;
	transition: transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
#kumonosu-canvas.kumonosu-is-moving {
	transition: none;
}
.kumonosu-wine-item {
	display: flex;
	flex-direction: column;
	align-items: center;
	text-align: center;
	user-select: none;
	width: var(--kumonosu-item-width);
}
.kumonosu-wine-bottle-container {
	height: var(--kumonosu-bottle-height);
	margin-bottom: 1.5rem;
	display: flex;
	align-items: center;
	justify-content: center;
}
.kumonosu-wine-bottle {
	height: 100%;
	filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.5));
	pointer-events: none;
	object-fit: contain;
}
.kumonosu-wine-info {
	max-width: 80%;
}
.kumonosu-wine-year-small {
	font-size: 0.9rem;
	letter-spacing: 0.2em;
	margin-bottom: 0.5rem;
	opacity: 0.8;
}
.kumonosu-wine-name {
	font-size: clamp(1rem, 4vw, 1.2rem);
	font-weight: 600;
	text-transform: uppercase;
	letter-spacing: 0.1em;
	margin-bottom: 0.5rem;
	line-height: 1.2;
}
.kumonosu-loading-overlay {
	position: absolute;
	inset: 0;
	background: #000;
	z-index: 2000;
	display: flex;
	justify-content: center;
	align-items: center;
	transition: opacity 0.8s ease, visibility 0.8s;
}
.kumonosu-loading-overlay.kumonosu-hidden {
	opacity: 0;
	visibility: hidden;
}
const winesData = [{
	year: 2018,
	name: "Muscat Ottonel",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/adsense.jpg"
}, {
	year: 2015,
	name: "Cabernet Franc",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176f.jpg"
}, {
	year: 2017,
	name: "Pinot Grigio",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176c.jpg"
}, {
	year: 2013,
	name: "Cabernet Sauvignon",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176d.jpg"
}, {
	year: 2016,
	name: "Sauvignon Blanc",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176b.jpg"
}, {
	year: 2014,
	name: "Merlot Reserve",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176a.jpg"
}, {
	year: 2019,
	name: "Syrah Blend",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176g.jpg"
}, {
	year: 2011,
	name: "Chardonnay",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176e.jpg"
}, {
	year: 2012,
	name: "Rose D'Anjou",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/260114c.jpeg"
}, {
	year: 2020,
	name: "Pinot Noir",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/260114b.jpeg"
}, {
	year: 2010,
	name: "Malbec",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/260114a.jpeg"
}, {
	year: 2021,
	name: "Riesling",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/02/neon26.jpg"
}, {
	year: 2014,
	name: "Merlot Reserve",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176a.jpg"
}, {
	year: 2019,
	name: "Syrah Blend",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176g.jpg"
}, {
	year: 2011,
	name: "Chardonnay",
	region: "France",
	img: "https://kumonosu.net/wp-content/uploads/2026/01/176e.jpg"
}];
const canvas = document.getElementById('kumonosu-canvas');
const yearDisplay = document.getElementById('kumonosu-year-display');
const loader = document.getElementById('kumonosu-loader');
// 1. グリッドの生成
winesData.forEach(wine => {
	const div = document.createElement('div');
	div.className = 'kumonosu-wine-item';
	div.dataset.year = wine.year;
	div.innerHTML = `
                <div class="kumonosu-wine-bottle-container">
                    <img src="${wine.img}" alt="${wine.name}" class="kumonosu-wine-bottle" loading="lazy">
                </div>
                <div class="kumonosu-wine-info">
                    <div class="kumonosu-wine-year-small kumonosu-serif italic">${wine.year}</div>
                    <div class="kumonosu-wine-name text-white">${wine.name}</div>
                    <div class="text-[9px] uppercase tracking-[0.2em] opacity-50">${wine.region}</div>
                </div>
            `;
	canvas.appendChild(div);
});
// 2. 位置管理
let scrollX = -2000;
let scrollY = -2000;
let isMoving = false;
let lastX, lastY;
let activeYear = "";
let moveTimeout;
const updateYearBackground = () => {
	const centerX = window.innerWidth / 2;
	const centerY = window.innerHeight / 2;
	const items = document.querySelectorAll('.kumonosu-wine-item');
	let closestYear = "";
	let minDistance = Infinity;
	items.forEach(item => {
		const rect = item.getBoundingClientRect();
		const dx = centerX - (rect.left + rect.width / 2);
		const dy = centerY - (rect.top + rect.height / 2);
		const dist = dx * dx + dy * dy;
		if (dist < minDistance) {
			minDistance = dist;
			closestYear = item.dataset.year;
		}
	});
	if (activeYear !== closestYear) {
		activeYear = closestYear;
		yearDisplay.classList.remove('kumonosu-visible');
		setTimeout(() => {
			yearDisplay.innerText = activeYear;
			yearDisplay.classList.add('kumonosu-visible');
		}, 300);
	}
};
const updateTransform = () => {
	canvas.style.transform = `translate3d(${scrollX}px, ${scrollY}px, 0)`;
	updateYearBackground();
};
const snapToNearest = () => {
	const centerX = window.innerWidth / 2;
	const centerY = window.innerHeight / 2;
	const items = document.querySelectorAll('.kumonosu-wine-item');
	let closestItem = null;
	let minDistance = Infinity;
	items.forEach(item => {
		const rect = item.getBoundingClientRect();
		const itemCenterX = rect.left + rect.width / 2;
		const itemCenterY = rect.top + rect.height / 2;
		const dist = Math.hypot(centerX - itemCenterX, centerY - itemCenterY);
		if (dist < minDistance) {
			minDistance = dist;
			closestItem = item;
		}
	});
	if (closestItem) {
		const rect = closestItem.getBoundingClientRect();
		scrollX += centerX - (rect.left + rect.width / 2);
		scrollY += centerY - (rect.top + rect.height / 2);
		canvas.classList.remove('kumonosu-is-moving');
		updateTransform();
	}
	isMoving = false;
};
const startMove = (x, y) => {
	isMoving = true;
	lastX = x;
	lastY = y;
	canvas.classList.add('kumonosu-is-moving');
	clearTimeout(moveTimeout);
};
const onMove = (x, y) => {
	if (!isMoving) return;
	scrollX += x - lastX;
	scrollY += y - lastY;
	lastX = x;
	lastY = y;
	updateTransform();
};
const endMove = () => {
	if (!isMoving) return;
	snapToNearest();
};
// イベント登録
window.addEventListener('mousedown', e => startMove(e.pageX, e.pageY));
window.addEventListener('mousemove', e => onMove(e.pageX, e.pageY));
window.addEventListener('mouseup', endMove);
window.addEventListener('touchstart', e => startMove(e.touches[0].pageX, e.touches[0].pageY), {
	passive: false
});
window.addEventListener('touchmove', e => onMove(e.touches[0].pageX, e.touches[0].pageY), {
	passive: false
});
window.addEventListener('touchend', endMove);
window.addEventListener('wheel', e => {
	if (!isMoving) {
		isMoving = true;
		canvas.classList.add('kumonosu-is-moving');
	}
	scrollX -= e.deltaX;
	scrollY -= e.deltaY;
	updateTransform();
	clearTimeout(moveTimeout);
	moveTimeout = setTimeout(endMove, 150);
}, {
	passive: true
});
// 初期化
window.onload = () => {
	loader.classList.add('kumonosu-hidden');
	updateTransform();
	setTimeout(snapToNearest, 100);
};
// リサイズ対応
window.addEventListener('resize', () => {
	snapToNearest();
});
https://cdn.tailwindcss.com

Explanation 詳しい説明

仕様

このギャラリーは、CSSでレイアウトを構築し、JavaScriptで移動制御とスナップ処理を行う構成になっています。描画には translate3d を使用し、GPUレンダリングを活用することで滑らかな動作を実現しています。

主な仕様は次の通りです。

・フルスクリーン構成の没入型レイアウト
・display grid を使った整列構造
・translate3d による高速描画
・ドラッグ操作とホイール操作に対応
・画面中央への自動スナップ機能
・中央アイテムの年号を背景にフェード表示

スナップ処理では、各アイテムの中心座標と画面中央との距離を計算し、最も近い要素が中央へ移動するよう座標を補正します。これにより、自由に操作しながらも、最終的には整ったフォーカス構造が維持されます。

カスタム

見た目と動きは主にCSS側で柔軟に調整できます。JavaScriptは構造制御が中心のため、デザイン変更の影響を受けにくい設計です。

カスタマイズできる主なポイントは次の通りです。

・アイテム幅は –kumonosu-item-width
・ボトル高さは –kumonosu-bottle-height
・グリッド間隔は –kumonosu-gap
・背景色は –bg-color
・スナップの動きは transition の cubic-bezier
・背景年号の透明度は opacity
・背景年号のサイズは font-size

メディアクエリ内でCSS変数を書き換えることで、スマートフォン向けの最適化も簡単に行えます。レイアウトの密度や余白も自由にコントロールできます。

注意点

インタラクティブ性が高い分、パフォーマンスや計算コストには注意が必要です。

注意点は次の通りです。

・getBoundingClientRect を全要素に対して実行している
・アイテム数が増えると距離計算の負荷が上がる
・高解像度画像はモバイルで負荷になる
・ホイールイベントの連続発火に注意
・大量データでは仮想化の検討が必要

要素数が増える場合は、可視範囲のみを対象にする最適化や、requestAnimationFrame を用いた描画制御への変更を検討すると安定します。

まとめ

この実装は、CSSによるレイアウト制御と、JavaScriptによる移動管理とスナップ処理を組み合わせた没入型ギャラリーです。

ドラッグやスクロールで自由に選べる操作体験と、中央吸着による視線誘導を両立できるのが最大の特徴です。商品一覧、ポートフォリオ、ブランドサイトなど、ビジュアルを主役にしたい場面に最適な構成です。