CSSとJSで作る、ドラッグとスクロールで巡るギャラリースライダー

スクロール

CSSとJSで作る、ドラッグとスクロールで巡るギャラリースライダー

投稿日2026/02/08

更新日2026/2/2

画像を「切り替える」のではなく、空間を移動しながら作品を眺めるようなギャラリー体験を作りたい。

このデモは、ドラッグ操作やスクロールによって横に広がる展示空間を巡れるギャラリースライダーです。

ただ画像が並ぶだけでなく、奥行き・距離感・余白を含めて“展示そのもの”を表現しています。

Preview プレビュー

Code コード

<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"

Explanation 詳しい説明

このギャラリースライダーでできること

このスライダーでは、次のような操作が可能です。

  • マウスドラッグで横方向に移動
  • マウスホイール(スクロール)でスライド切り替え
  • 指のスワイプ操作にも対応(スマホ・タブレット)
  • 作品ごとにテキスト情報を同期表示

操作に応じて、最も近い作品に自然にスナップする挙動も組み込まれています。

スライダーの動きの特徴

このデモの最大の特徴は、「横スクロール + 奥行きのある視点移動」です。

  • カメラが横方向に移動する
  • 壁に並んだ作品を順番に通り過ぎる
  • 現在表示中の作品に合わせてUIが切り替わる

そのため、単なるスライダーではなく展示空間を歩いているような感覚が生まれます。

実装のポイント

  • CSSでUI・レイアウト・タイポグラフィを構成
  • JavaScriptでドラッグ・スクロール操作を制御
  • 慣性移動(lerp)によるなめらかなカメラ移動
  • 一定時間操作が止まると、最寄りの作品にスナップ

視覚的な派手さよりも、触っていて気持ちいい操作感を重視しています。

カスタムできる主な項目

コード内の設定を変更することで、次の調整が可能です。

  • スライド(作品)の枚数
  • 作品同士の間隔
  • スナップの強さ・タイミング
  • カメラの距離や角度
  • ドラッグ・スクロールの感度

画像とテキストを差し替えるだけで、自分だけのギャラリーに作り替えられます。

注意点

  • WebGLを使用しているため、非常に古い端末では動作が重くなる可能性があります
  • 情報量が多いページより、ビジュアル重視のサイト向けです
  • 作品点数が多すぎる場合はパフォーマンス調整が必要です

こんな用途におすすめ

  • アート作品のオンライン展示
  • 写真・映像ポートフォリオ
  • ブランドや世界観を見せるLP
  • 実験的なWeb表現のデモ