HTML / CSS
スクロール
2026/02/08
2026/2/2
画像を「切り替える」のではなく、空間を移動しながら作品を眺めるようなギャラリー体験を作りたい。
このデモは、ドラッグ操作やスクロールによって横に広がる展示空間を巡れるギャラリースライダーです。
ただ画像が並ぶだけでなく、奥行き・距離感・余白を含めて“展示そのもの”を表現しています。
<div class="kumonosu-main-wrapper">
<div class="kumonosu-logo">KUMONOSU GALLERY</div>
<div id="kumonosu-canvas-container"></div>
<div id="kumonosu-ui-layer">
<div class="kumonosu-slide-content" id="kumonosu-slide-0">
<span class="kumonosu-catalogue-number">Vol. 01 / Peaks</span>
<h1>Summit <br>Serenity</h1>
<div class="kumonosu-description">標高三千メートル、雲を超えた先に広がる静寂。峻険な山脈が描き出すラインは、自然界が持つ最も根源的な幾何学です。</div>
<div class="kumonosu-meta-grid">
<span class="kumonosu-meta-label">Artist</span> <span class="kumonosu-meta-value">Erik Lindgren</span>
<span class="kumonosu-meta-label">Year</span> <span class="kumonosu-meta-value">2024</span>
<span class="kumonosu-meta-label">Medium</span> <span class="kumonosu-meta-value">Film Photography</span>
</div>
</div>
<div class="kumonosu-slide-content" id="kumonosu-slide-1">
<span class="kumonosu-catalogue-number">Vol. 02 / Abstract</span>
<h1>Indigo <br>Echoes</h1>
<div class="kumonosu-description">深い藍色のグラデーションが重なり合う抽象的表現。感情の深淵を覗き込むようなこの作品は、静かな対話を生み出します。</div>
<div class="kumonosu-meta-grid">
<span class="kumonosu-meta-label">Artist</span> <span class="kumonosu-meta-value">Maya Chen</span>
<span class="kumonosu-meta-label">Year</span> <span class="kumonosu-meta-value">2023</span>
<span class="kumonosu-meta-label">Medium</span> <span class="kumonosu-meta-value">Oil on Canvas</span>
</div>
</div>
<div class="kumonosu-slide-content" id="kumonosu-slide-2">
<span class="kumonosu-catalogue-number">Vol. 03 / Structure</span>
<h1>Concrete <br>Poetry</h1>
<div class="kumonosu-description">コンクリートが描き出すミニマルな造形。光と影が織りなすコントラストは、重厚な物質感の中に一筋の静謐さを見出します。</div>
<div class="kumonosu-meta-grid">
<span class="kumonosu-meta-label">Artist</span> <span class="kumonosu-meta-value">Hugo Rossi</span>
<span class="kumonosu-meta-label">Year</span> <span class="kumonosu-meta-value">2022</span>
<span class="kumonosu-meta-label">Medium</span> <span class="kumonosu-meta-value">Architecture Photo</span>
</div>
</div>
<div class="kumonosu-slide-content" id="kumonosu-slide-3">
<span class="kumonosu-catalogue-number">Vol. 04 / Flora</span>
<h1>Botanical <br>Minimalism</h1>
<div class="kumonosu-description">一枚の葉が持つ複雑なディテールへの賛辞。過剰な情報を削ぎ落とすことで、生命が持つ緻密な設計図を際立たせています。</div>
<div class="kumonosu-meta-grid">
<span class="kumonosu-meta-label">Artist</span> <span class="kumonosu-meta-value">Sarah Jenkins</span>
<span class="kumonosu-meta-label">Year</span> <span class="kumonosu-meta-value">2024</span>
<span class="kumonosu-meta-label">Medium</span> <span class="kumonosu-meta-value">Macro Photography</span>
</div>
</div>
</div>
<div class="kumonosu-scroll-hint">Scroll or Drag to explore</div>
</div>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.kumonosu-main-wrapper {
width: 100%;
height: 100%;
background-color: #f2f2f0;
font-family: 'Lato', sans-serif;
color: #111;
position: relative;
overflow: hidden;
cursor: grab;
user-select: none;
}
.kumonosu-main-wrapper:active {
cursor: grabbing;
}
#kumonosu-canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
#kumonosu-ui-layer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
}
.kumonosu-logo {
position: fixed;
top: clamp(20px, 4vh, 40px);
left: 50%;
transform: translateX(-50%);
/* スマホでは中央 */
font-family: 'Playfair Display', serif;
font-weight: 700;
letter-spacing: 2px;
font-size: clamp(0.7rem, 2vw, 0.9rem);
text-transform: uppercase;
z-index: 10;
}
@media (min-width: 768px) {
.kumonosu-logo {
left: 50px;
transform: none;
/* デスクトップでは左寄せ */
}
}
.kumonosu-slide-content {
position: absolute;
opacity: 0;
transition: opacity 0.8s ease, transform 0.8s ease-out;
pointer-events: auto;
box-sizing: border-box;
}
/* --- モバイルレイアウト(画像が中央、テキストは下) --- */
@media (max-width: 767px) {
.kumonosu-slide-content {
bottom: 8%;
left: 5%;
width: 90%;
background: rgba(242, 242, 240, 0.8);
backdrop-filter: blur(10px);
padding: 20px;
transform: translateY(30px);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.kumonosu-slide-content.kumonosu-active {
opacity: 1;
transform: translateY(0) !important;
}
}
/* --- デスクトップレイアウト(画像は右、テキストは左) --- */
@media (min-width: 768px) {
.kumonosu-slide-content {
top: 25%;
left: 8%;
width: 35%;
max-width: 480px;
transform: translateY(20px);
}
.kumonosu-slide-content.kumonosu-active {
opacity: 1;
transform: translateY(0) !important;
}
}
.kumonosu-slide-content h1 {
font-family: 'Playfair Display', serif;
font-weight: 400;
font-style: italic;
font-size: clamp(1.8rem, 6vw, 3.8rem);
margin: 0 0 0.8rem 0;
line-height: 1.1;
color: #0d0d0d;
}
.kumonosu-catalogue-number {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 2px;
color: #999;
margin-bottom: 0.8rem;
display: inline-block;
border-bottom: 1px solid #ccc;
padding-bottom: 3px;
}
.kumonosu-description {
font-size: clamp(0.8rem, 2vw, 1rem);
font-weight: 300;
line-height: 1.6;
color: #444;
margin-bottom: 1.5rem;
text-align: justify;
}
.kumonosu-meta-grid {
display: grid;
grid-template-columns: 70px 1fr;
row-gap: 0.5rem;
border-top: 1px solid #d0d0d0;
padding-top: 1rem;
}
.kumonosu-meta-label {
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #888;
align-self: center;
}
.kumonosu-meta-value {
font-family: 'Playfair Display', serif;
font-size: clamp(0.85rem, 2vw, 1rem);
font-style: italic;
color: #222;
}
.kumonosu-scroll-hint {
position: fixed;
bottom: clamp(20px, 4vh, 40px);
left: 50%;
transform: translateX(-50%);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 2px;
color: #aaa;
}
@media (min-width: 768px) {
.kumonosu-scroll-hint {
left: 50px;
transform: none;
}
}
const CONFIG = {
slideCount: 4,
spacingX: 45,
pWidth: 14,
pHeight: 18,
camZ: 30,
wallAngleY: -0.25,
snapDelay: 200,
lerpSpeed: 0.06
};
const totalGalleryWidth = CONFIG.slideCount * CONFIG.spacingX;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf2f2f0);
scene.fog = new THREE.Fog(0xf2f2f0, 10, 110);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, CONFIG.camZ);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.getElementById('kumonosu-canvas-container').appendChild(renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 0.7);
scene.add(ambient);
const galleryGroup = new THREE.Group();
scene.add(galleryGroup);
const textureLoader = new THREE.TextureLoader();
textureLoader.setCrossOrigin('anonymous');
const images = [
'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=1000&q=80',
'https://images.unsplash.com/photo-1550684848-fac1c5b4e853?w=1000&q=80',
'https://images.unsplash.com/photo-1511818966892-d7d671e672a2?w=1000&q=80',
'https://images.unsplash.com/photo-1501004318641-b39e6451bec6?w=1000&q=80'
];
const paintingGroups = [];
const planeGeo = new THREE.PlaneGeometry(CONFIG.pWidth, CONFIG.pHeight);
for(let i=0; i<CONFIG.slideCount; i++) {
const group = new THREE.Group();
group.position.set(i * CONFIG.spacingX, 0, 0);
const mat = new THREE.MeshBasicMaterial({ color: 0xe8e8e4 });
textureLoader.load(images[i], (tex) => {
mat.map = tex;
mat.needsUpdate = true;
});
const mesh = new THREE.Mesh(planeGeo, mat);
const outline = new THREE.LineSegments(
new THREE.EdgesGeometry(planeGeo),
new THREE.LineBasicMaterial({ color: 0x111111 })
);
const shadow = new THREE.Mesh(
planeGeo,
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.1 })
);
shadow.position.set(0.6, -0.6, -0.4);
const lineZ = -1;
const lineLen = CONFIG.spacingX;
const lineGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-lineLen/2, 14, lineZ), new THREE.Vector3(lineLen/2, 14, lineZ),
new THREE.Vector3(-lineLen/2, -14, lineZ), new THREE.Vector3(lineLen/2, -14, lineZ)
]);
const lines = new THREE.LineSegments(lineGeo, new THREE.LineBasicMaterial({ color: 0xcccccc }));
group.add(shadow, mesh, outline, lines);
galleryGroup.add(group);
paintingGroups.push(group);
}
galleryGroup.rotation.y = CONFIG.wallAngleY;
let currentScroll = 0;
let targetScroll = 0;
let snapTimer = null;
let mouse = { x: 0, y: 0 };
let isDragging = false;
let startX = 0;
function snapToNearest() {
const index = Math.round(targetScroll / CONFIG.spacingX);
targetScroll = index * CONFIG.spacingX;
}
window.addEventListener('wheel', (e) => {
targetScroll += e.deltaY * 0.1;
if(snapTimer) clearTimeout(snapTimer);
snapTimer = setTimeout(snapToNearest, CONFIG.snapDelay);
}, { passive: true });
const handlePointerDown = (clientX) => {
isDragging = true;
startX = clientX;
if(snapTimer) clearTimeout(snapTimer);
};
const handlePointerMove = (clientX) => {
if (!isDragging) return;
const dx = startX - clientX;
targetScroll += dx * 0.15;
startX = clientX;
if(snapTimer) clearTimeout(snapTimer);
};
const handlePointerUp = () => {
isDragging = false;
snapToNearest();
};
window.addEventListener('mousedown', e => handlePointerDown(e.clientX));
window.addEventListener('mousemove', e => {
handlePointerMove(e.clientX);
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
});
window.addEventListener('mouseup', handlePointerUp);
window.addEventListener('touchstart', e => handlePointerDown(e.touches[0].clientX), { passive: true });
window.addEventListener('touchmove', e => handlePointerMove(e.touches[0].clientX), { passive: true });
window.addEventListener('touchend', handlePointerUp);
function updateUI(scrollX) {
const rawIndex = Math.round(scrollX / CONFIG.spacingX);
const safeIndex = ((rawIndex % CONFIG.slideCount) + CONFIG.slideCount) % CONFIG.slideCount;
for(let i=0; i<CONFIG.slideCount; i++) {
const el = document.getElementById(`kumonosu-slide-${i}`);
if(el) {
if(i === safeIndex) el.classList.add('kumonosu-active');
else el.classList.remove('kumonosu-active');
}
}
}
function onWindowResize() {
const width = window.innerWidth;
const height = window.innerHeight;
const aspect = width / height;
camera.aspect = aspect;
if (aspect < 1) {
// スマホ:画像を中央に配置
camera.fov = 65;
galleryGroup.position.x = 0;
} else {
// PC:画像を右側に寄せて、左側にテキストスペースを作る
camera.fov = 45;
galleryGroup.position.x = 8;
}
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
window.addEventListener('resize', onWindowResize);
onWindowResize();
function animate() {
requestAnimationFrame(animate);
currentScroll += (targetScroll - currentScroll) * CONFIG.lerpSpeed;
const xMove = currentScroll * Math.cos(CONFIG.wallAngleY);
const zMove = currentScroll * Math.sin(CONFIG.wallAngleY);
camera.position.x = xMove;
camera.position.z = CONFIG.camZ - zMove;
paintingGroups.forEach((group, i) => {
const originalX = i * CONFIG.spacingX;
const distFromCam = currentScroll - originalX;
const shift = Math.round(distFromCam / totalGalleryWidth) * totalGalleryWidth;
group.position.x = originalX + shift;
});
camera.rotation.x = mouse.y * 0.05;
camera.rotation.y = -mouse.x * 0.05;
updateUI(currentScroll);
renderer.render(scene, camera);
}
animate();
https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
このスライダーでは、次のような操作が可能です。
操作に応じて、最も近い作品に自然にスナップする挙動も組み込まれています。
このデモの最大の特徴は、「横スクロール + 奥行きのある視点移動」です。
そのため、単なるスライダーではなく展示空間を歩いているような感覚が生まれます。
視覚的な派手さよりも、触っていて気持ちいい操作感を重視しています。
コード内の設定を変更することで、次の調整が可能です。
画像とテキストを差し替えるだけで、自分だけのギャラリーに作り替えられます。