CSSとJSで作る、スクロールで画像が順番に切り替わるスライダー

スクロール

CSSとJSで作る、スクロールで画像が順番に切り替わるスライダー

投稿日2026/02/20

更新日2026/2/14

スクロールに合わせて、画像が順番に切り替わるスライダーを作りたい。

このサンプルは、画像をレイヤーとして重ね、スクロールの進行に応じて1枚ずつ表示が切り替わる仕組みです。右側のドットナビから任意の位置へ移動することもできます。

Preview プレビュー

Code コード

<main>
	<section class="kumonosu-scroll-section" id="kumonosu-main-comparator">
		<div class="kumonosu-comparator-container">
			<div class="kumonosu-comparator-wrapper">
				<div class="kumonosu-comparator">
					<div class="kumonosu-image-layers">
						<div class="kumonosu-image-layer"><img src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&fit=crop&w=1200&q=80" alt=""></div>
						<div class="kumonosu-image-layer"><img src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=1200&q=80" alt=""></div>
						<div class="kumonosu-image-layer"><img src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=1200&q=80" alt=""></div>
						<div class="kumonosu-image-layer"><img src="https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?auto=format&fit=crop&w=1200&q=80" alt=""></div>
					</div>
					<div class="kumonosu-divider-lines">
						<div class="kumonosu-divider-line"></div>
						<div class="kumonosu-divider-line"></div>
						<div class="kumonosu-divider-line"></div>
					</div>
				</div>
			</div>
		</div>
	</section>
	<section class="kumonosu-spacer"></section>
</main>
@property --scroll-progress {
	inherits: true;
	initial-value: 0;
	syntax: "<number>";
}
@property --layer-index {
	syntax: "<integer>";
	inherits: true;
	initial-value: 1;
}
@property --layer-count {
	syntax: "<integer>";
	inherits: true;
	initial-value: 1;
}
@layer reset, base, layout, comparator, navigation;
@layer reset {
	*, *::after, *::before {
		box-sizing: border-box;
		margin: 0;
		padding: 0;
	}
	html {
		color-scheme: light dark;
		overflow-y: scroll;
	}
}
@layer base {
	:root {
		--kumonosu-color-bg: #fafafa;
		--kumonosu-duration: 400vh;
		--kumonosu-max-width: 56.25rem;
		--kumonosu-max-height: 100vh;
		--kumonosu-aspect-ratio: 4/3;
	}
	@media (max-width: 48em) {
		:root {
			--kumonosu-aspect-ratio: 3/4;
		}
	}
	@media (prefers-color-scheme: dark) {
		:root {
			--kumonosu-color-bg: #1f1408;
		}
	}
	body {
		background: var(--kumonosu-color-bg)!important;
		min-block-size: 100vh;
	}
}
@layer layout {
	.kumonosu-scroll-section {
		block-size: calc(var(--kumonosu-duration) + 100vh);
		position: relative;
	}
	.kumonosu-spacer {
		block-size: 50vh;
	}
}
@layer comparator {
	.kumonosu-comparator-container {
		align-items: center;
		block-size: 100vh;
		display: flex;
		inset-block-start: 0;
		justify-content: center;
		overflow: hidden;
		position: sticky;
	}
	.kumonosu-comparator-wrapper {
		opacity: 1;
		aspect-ratio: var(--kumonosu-aspect-ratio);
		border-radius: 0.5rem;
		inline-size: 100%;
		margin-inline: 10rem;
		max-block-size: var(--kumonosu-max-height);
		max-inline-size: var(--kumonosu-max-width);
		overflow: hidden;
		position: relative;
		box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
	}
	.kumonosu-comparator {
		animation: kumonosu-progress-calc linear both;
		animation-range: 0vh var(--kumonosu-duration);
		animation-timeline: scroll(root);
		block-size: 100%;
		display: grid;
		position: relative;
	}
	.kumonosu-image-layers {
		grid-area: 1 / -1;
		display: grid;
		position: relative;
	}
	.kumonosu-image-layer {
		display: grid;
		grid-area: 1 / -1;
		position: relative;
		z-index: calc(sibling-count() - sibling-index() + 1);
	}
	.kumonosu-image-layer:not(:last-child) {
		--layer-index: sibling-index();
		--layer-count: sibling-count();
		--layer-start: calc((var(--layer-index) - 1) / (var(--layer-count) - 1));
		--layer-end: calc(var(--layer-index) / (var(--layer-count) - 1));
		animation: kumonosu-clip-reveal linear both;
		animation-timeline: scroll(root);
		animation-range: calc(var(--kumonosu-duration) * var(--layer-start)) calc(var(--kumonosu-duration) * var(--layer-end));
	}
	.kumonosu-image-layer img {
		block-size: 100%;
		display: block;
		inline-size: 100%;
		object-fit: cover;
		aspect-ratio: var(--kumonosu-aspect-ratio);
		background: #333;
	}
	.kumonosu-divider-lines {
		grid-area: 1 / -1;
		display: grid;
		position: relative;
		pointer-events: none;
		z-index: 100;
	}
	.kumonosu-divider-line {
		--divider-index: sibling-index();
		--divider-count: sibling-count();
		--layer-start: calc((var(--divider-index) - 1) / var(--divider-count));
		--layer-end: calc(var(--divider-index) / var(--divider-count));
		background: rgba(255, 255, 255, 0.7);
		block-size: 100%;
		grid-area: 1 / -1;
		inline-size: 1px;
		position: relative;
		animation: kumonosu-divider-move linear both;
		animation-timeline: scroll(root);
		animation-range: calc(var(--kumonosu-duration) * var(--layer-start)) calc(var(--kumonosu-duration) * var(--layer-end));
	}
	@keyframes kumonosu-progress-calc {
		from {
			--scroll-progress: 0;
		}
		to {
			--scroll-progress: 100;
		}
	}
	@keyframes kumonosu-clip-reveal {
		from {
			clip-path: inset(0 0 0 0);
		}
		to {
			clip-path: inset(0 100% 0 0);
		}
	}
	@keyframes kumonosu-divider-move {
		0% {
			inset-inline-start: 100%;
			opacity: 0;
		}
		1%, 99% {
			opacity: 1;
		}
		100% {
			inset-inline-start: 0%;
			opacity: 0;
		}
	}
}
@layer navigation {
	.kumonosu-stage-nav {
		display: flex;
		flex-direction: column;
		gap: 0.6rem;
		position: absolute;
		right: 1.5rem;
		top: 50%;
		transform: translateY(-50%);
		z-index: 200;
	}
	.kumonosu-stage-indicator {
		appearance: none;
		background: rgba(0, 0, 0, 0.2);
		border: 1px solid rgba(255, 255, 255, 0.3);
		border-radius: 50%;
		cursor: pointer;
		height: 0.7rem;
		width: 0.7rem;
		transition: all 0.3s ease;
	}
	.kumonosu-stage-indicator.kumonosu-active {
		background: #fff;
		transform: scale(1.3);
	}
	@media (max-width: 48em) {
		.kumonosu-stage-nav {
			flex-direction: row;
			right: 50%;
			top: auto;
			bottom: 1.5rem;
			transform: translateX(50%);
		}
	}
}
(function() {
	"use strict";
	let velocity = 0;
	const ease = 0.1;
	const friction = 0.93;
	const section = document.querySelector(".kumonosu-scroll-section");
	const comparator = section.querySelector(".kumonosu-comparator");
	const layers = section.querySelectorAll(".kumonosu-image-layer");
	const indicators = [];

	function createStageIndicators() {
		const nav = document.createElement("div");
		nav.className = "kumonosu-stage-nav";
		layers.forEach((_, j) => {
			const indicator = document.createElement("button");
			indicator.className = "kumonosu-stage-indicator";
			indicator.dataset.stage = j;
			indicators.push(indicator);
			nav.appendChild(indicator);
		});
		comparator.appendChild(nav);
	}

	function getComparatorDuration() {
		const style = getComputedStyle(document.documentElement);
		const duration = style.getPropertyValue("--kumonosu-duration").trim();
		return (parseFloat(duration) * window.innerHeight) / 100;
	}
	let targetScrollPosition = null;
	const scrollEase = 0.08;

	function scrollToStage(stageIndex) {
		const duration = getComparatorDuration();
		const stageDuration = duration / (layers.length - 1);
		targetScrollPosition = stageDuration * stageIndex;
	}

	function frame() {
		if (targetScrollPosition !== null) {
			const delta = targetScrollPosition - window.scrollY;
			if (Math.abs(delta) > 1) {
				window.scrollBy(0, delta * scrollEase);
			} else {
				targetScrollPosition = null;
			}
		}
		velocity *= friction;
		if (Math.abs(velocity) > 0.1) {
			window.scrollBy(0, velocity * ease);
		}
		const v = parseFloat(getComputedStyle(comparator).getPropertyValue("--scroll-progress")) || 0;
		const currentStage = Math.round((v / 100) * (layers.length - 1));
		indicators.forEach((ind, idx) => {
			ind.classList.toggle("kumonosu-active", idx === currentStage);
		});
		requestAnimationFrame(frame);
	}
	window.addEventListener("wheel", e => {
		e.preventDefault();
		targetScrollPosition = null;
		velocity += e.deltaY;
	}, {
		passive: false
	});
	document.addEventListener("click", e => {
		const btn = e.target.closest(".kumonosu-stage-indicator");
		if (btn) scrollToStage(parseInt(btn.dataset.stage));
	});
	window.addEventListener("load", () => {
		createStageIndicators();
		requestAnimationFrame(frame);
	});
})();

Explanation 詳しい説明

仕様

表示部分はposition: stickyで画面に固定し、ページ全体のスクロール量を演出に変換します。.kumonosu-comparatorにはスクロールタイムライン(animation-timeline: scroll(root))を設定し、--scroll-progressを0→100へ進めています。

各レイヤー(.kumonosu-image-layer)は重ねた状態で、上にあるレイヤーほど手前に来るようz-indexを計算しています。

レイヤーの切り替えはclip-path: inset(...)のアニメーションです。sibling-index()sibling-count()で「今のレイヤーが全体の何番目か」を取り、animation-rangeを分割して、スクロールの一定区間ごとに1枚ずつ削れていくようにしています。

区切り線(.kumonosu-divider-line)も同様に区間を分け、右→左へ移動しながらフェードすることで「今どの段階か」を視覚化しています。

JS側は2つの役割だけに絞っています。ひとつはホイールスクロールを慣性付きにして操作感を整えること、もうひとつは段階ナビ(ドット)を自動生成し、クリックで該当スクロール位置へイージング移動することです。

現在段階は、CSSで計算される--scroll-progressを読み取って丸め、アクティブなドットに反映しています。

  • stickyで表示を固定し、スクロールを演出に変換
  • CSSのスクロールタイムラインで--scroll-progressを進行
  • レイヤーはclip-pathで区間ごとに順番に露出
  • 区切り線も同じ区間設計で移動・フェード
  • JSは慣性スクロール+ドットナビ生成+段階ジャンプのみ

カスタム

カスタムは「何秒(何vh)で比較を終えるか」「何枚を比較するか」「切り替えの気持ちよさ」を押さえると作りやすいです。画像枚数を増やす場合も、基本はレイヤーを足すだけで区間が自動的に分割されます。

  • 比較の長さ:--kumonosu-duration(例:400vh
  • 表示サイズ:--kumonosu-max-width / --kumonosu-aspect-ratio
  • レイヤー枚数:.kumonosu-image-layerを増減(区切り線は枚数-1が目安)
  • 露出方向:kumonosu-clip-revealclip-path(右→左以外にも変更可能)
  • 慣性の強さ:JSのfriction / ease、段階ジャンプのscrollEase

ドットナビはレイヤー数に合わせて自動生成されるので、デザインだけCSSで変えるのが簡単です(サイズ・位置・色など)。

注意点

この実装はスクロールタイムラインやsibling-index()/count()などの新しめのCSS機能に依存します。未対応環境ではアニメーションが動かず、比較演出が成立しません。

実運用では対応ブラウザの明示か、非対応時のフォールバック(単純な画像一覧など)を用意すると安全です。

また、ホイールイベントをpreventDefault()して独自スクロールに置き換えているため、ページ全体のスクロール挙動に影響します。

埋め込み先のサイトで他のスクロール制御と競合しないよう注意が必要です。スマホはホイールがないので、必要ならタッチドラッグ対応を追加します。

  • 新しめのCSS機能依存(未対応ブラウザでは動かない)
  • wheelを握って独自スクロールにするため競合注意
  • スマホ操作は別途配慮(タッチでの段階移動など)