スクロール
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-revealのclip-path(右→左以外にも変更可能) - 慣性の強さ:JSの
friction/ease、段階ジャンプのscrollEase
ドットナビはレイヤー数に合わせて自動生成されるので、デザインだけCSSで変えるのが簡単です(サイズ・位置・色など)。
注意点
この実装はスクロールタイムラインやsibling-index()/count()などの新しめのCSS機能に依存します。未対応環境ではアニメーションが動かず、比較演出が成立しません。
実運用では対応ブラウザの明示か、非対応時のフォールバック(単純な画像一覧など)を用意すると安全です。
また、ホイールイベントをpreventDefault()して独自スクロールに置き換えているため、ページ全体のスクロール挙動に影響します。
埋め込み先のサイトで他のスクロール制御と競合しないよう注意が必要です。スマホはホイールがないので、必要ならタッチドラッグ対応を追加します。
- 新しめのCSS機能依存(未対応ブラウザでは動かない)
wheelを握って独自スクロールにするため競合注意- スマホ操作は別途配慮(タッチでの段階移動など)