アニメーション
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による移動管理とスナップ処理を組み合わせた没入型ギャラリーです。
ドラッグやスクロールで自由に選べる操作体験と、中央吸着による視線誘導を両立できるのが最大の特徴です。商品一覧、ポートフォリオ、ブランドサイトなど、ビジュアルを主役にしたい場面に最適な構成です。