
スライダーのように無限ループでスライドさせたい、でもスライダーだと思ったような動きにならない…と思ったことはありませんか?
スライダーは、スライドの横幅分動いてしまいます。少しずつ動かしたいといった場合、スライドを余分に増やして調整するしかありません。
ですが、こちらの手法はあまり現実的ではありません。
そこで本記事では、スライダープラグインを使わず、少しずつ無限ループスライダーの実装方法をご紹介します。
今回作成するスライダー
前へボタンを押したらヒツジが前に進み、次へボタンを押したら後ろに戻っていくような無限ループスクロールです。
スライドする距離はスライド一つ分ではなく、スライド1枚の任意の平均幅分ずつ進んでいきます。
ループが始まる際は、次のスライドが複製され途切れることなくループしているように見せることで無限ループスクロールを実装しています。
デモはこちらです。
無限ループスライダー
前提
まず横スクロールで無限ループスクロールするにあたり、スクロール管理のため、GSAPを使います。
必要なファイルは以下の三つです。
/gsap-public/minified/gsap.min.js
GSAPを使用するのに必要なファイル
/gsap-public/minified/ScrollTrigger.min.js
スクロールのイベント判定のために必要なファイル
/gsap-public/minified/Draggable.min.js
ドラッグでも動かすために必要なファイル
上記3つのファイルをダウンロードして、適宜読み込んでください。
ダウンロードはこちらからできます。
作成の手順
最終的なコードはこちらです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | <div class="slider-wrap"> <h1 class="c-ttl">無限ループスライダー</h1> <div class="slider-area"> <div class="slide"> <span class="start-line">スタート</span> <img src="./img/slide02.jpg.webp" alt=""> </div> <div class="slide"> <img src="./img/slide01.jpg.webp" alt=""> </div> <div class="slide"> <img src="./img/slide04.jpg.webp" alt=""> </div> <div class="slide"> <img src="./img/slide03.jpg.webp" alt=""> </div> </div> <div class="btn-wrap"> <button class="arrow-btn prev"> </button> <button class="arrow-btn next"> </button> </div> <div class="hiyoko is-scroll"> <img src="./img/hitsuji.svg" alt=""> </div> </div> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | .home .slider-wrap { position: relative; } .home .slider-wrap .slider-area { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; display: flex; -webkit-box-align: start; -webkit-align-items: flex-start; -ms-flex-align: start; align-items: flex-start; overflow: hidden; -webkit-transform: translateZ(0); transform: translateZ(0); height: 100vh; width: 100vw; } .home .slider-wrap .slide { position: relative; } .home .slider-wrap .slide:not(:nth-child(3)) { min-width: 100vw; } .home .slider-wrap .slide:nth-child(3) { min-width: 42.8571428571vw; } .home .slider-wrap .slide img { display: block; max-width: calc(100% + 4px); width: calc(100% + 4px); height: 100vh; max-height: 100vh; } .home .slider-wrap .start-line { color: rgb(165, 22, 22); font-size: 20px; font-weight: bold; position: absolute; bottom: 50px; left: 0; right: 0; margin: auto; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; } .home .slider-wrap .start-line::before { background: rgb(165, 22, 22); content: ""; height: 50px; width: 4px; position: absolute; bottom: -50px; left: 0; right: 0; margin: auto; } .home .slider-wrap .btn-wrap { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; display: flex; -webkit-flex-wrap: wrap; -ms-flex-wrap: wrap; flex-wrap: wrap; -webkit-box-align: center; -webkit-align-items: center; -ms-flex-align: center; align-items: center; -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; position: absolute; top: 0; bottom: 0; margin: auto; padding: 0 20px; height: -webkit-fit-content; height: -moz-fit-content; height: fit-content; width: 100%; } .home .slider-wrap .btn-wrap .arrow-btn { background: #925D27; border-radius: 100px; height: 60px; width: 60px; position: relative; } .home .slider-wrap .btn-wrap .arrow-btn::before { background: url(../img/ico-arrow.svg) no-repeat center center/contain; content: ""; height: 23px; width: 13px; position: absolute; top: 0; bottom: 0; right: 0; left: 0; margin: auto; } .home .slider-wrap .btn-wrap .arrow-btn.prev { -webkit-transform: scale(-1, 1); transform: scale(-1, 1); } .home .slider-wrap .hiyoko { position: absolute; bottom: 5.5714285714vw; right: 4.5vw; } .home .slider-wrap .hiyoko.is-scroll { -webkit-animation: pyon 1s cubic-bezier(0.12, 0, 0.39, 0) 1 forwards; animation: pyon 1s cubic-bezier(0.12, 0, 0.39, 0) 1 forwards; } .home .slider-wrap .hiyoko.is-back img { -webkit-transform: scale(-1, 1); transform: scale(-1, 1); } @-webkit-keyframes pyon { 0% { -webkit-transform: translateY(1px); transform: translateY(1px); } 30% { -webkit-transform: translateY(-10px); transform: translateY(-10px); } 100% { -webkit-transform: translateX(0); transform: translateX(0); } } @keyframes pyon { 0% { -webkit-transform: translateY(1px); transform: translateY(1px); } 30% { -webkit-transform: translateY(-10px); transform: translateY(-10px); } 100% { -webkit-transform: translateX(0); transform: translateX(0); } } @media screen and (width < 768px) { html { font-size: 14px; } body { position: relative; -webkit-appearance: none; -webkit-text-size-adjust: 100%; } input, select, textarea { font-size: 16px !important; } #wrapper { min-width: 320px; } .inner-block { padding-inline: 20px; } .pc { display: none !important; } } @media screen and (768px <=width) { a[href^="tel:"] { pointer-events: none; } .inner-block { padding-inline: 40px; max-width: 1280px; } a, a::before, a::after, button, button::before, button::after { -webkit-transition: 0.3s ease-in-out; transition: 0.3s ease-in-out; } .sp { display: none !important; } .home .slider-wrap .btn-wrap .arrow-btn:hover { opacity: 0.8; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | $(function($) { // スライダーのような動きを実装する内容 const initSlider = ( wrapperSelector, boxSelector, ) => { const $wrapper = $(wrapperSelector); if ($wrapper.length === 0) return; // 要素がない場合は処理しない const boxes = gsap.utils.toArray(boxSelector); if (boxes.length === 0) return; // ボックスがない場合は処理しない const loop = horizontalLoop(boxes, { // ループ paused: true, draggable: true, speed: 1, repeat: -1, paddingRight: 0, }); // next/prevボタン $(".next").on("click", () => { const totalWidth = boxes.reduce( (sum, box) => sum + box.offsetWidth, 0, ); const duration = loop.duration(); let moveAmount; const averageBoxWidth = totalWidth / boxes.length; moveAmount = ((averageBoxWidth * 0.4) / totalWidth) * duration; gsap.to(loop, { time: "+=" + moveAmount, duration: 0.8, ease: "power1.inOut", modifiers: { time: gsap.utils.wrap(0, duration), }, }); back(); timeout(); }); $(".prev").on("click", () => { const totalWidth = boxes.reduce( (sum, box) => sum + box.offsetWidth, 0, ); const duration = loop.duration(); let moveAmount; const averageBoxWidth = totalWidth / boxes.length; moveAmount = ((averageBoxWidth * 0.4) / totalWidth) * duration; gsap.to(loop, { time: "-=" + moveAmount, duration: 0.8, ease: "power1.inOut", modifiers: { time: gsap.utils.wrap(0, duration), }, }); forward(); timeout(); }); }; initSlider( ".slider-area", ".slider-area .slide", ); // ループさせるための内容 function horizontalLoop(items, config) { items = gsap.utils.toArray(items); config = config || {}; let tl = gsap.timeline({ repeat: config.repeat, paused: config.paused, defaults: { ease: "none", }, snap: { x: 1, // 1pxスナップ }, onReverseComplete: () => tl.totalTime(tl.rawTime() + tl.duration() * 100), }), length = items.length, startX = items[0].offsetLeft, times = [], widths = [], xPercents = [], curIndex = 0, pixelsPerSecond = (config.speed || 1) * 100, snap = config.snap === false ? (v) => v : gsap.utils.snap(config.snap || 1), populateWidths = () => items.forEach((el, i) => { // Math.roundではなくMath.ceilで切り上げ widths[i] = Math.ceil( parseFloat(gsap.getProperty(el, "width", "px")), ); xPercents[i] = Math.floor( snap( (parseFloat(gsap.getProperty(el, "x", "px")) / widths[i]) * 100 + gsap.getProperty(el, "xPercent"), ), ); }), getTotalWidth = () => items[length - 1].offsetLeft + (xPercents[length - 1] / 100) * widths[length - 1] - startX + items[length - 1].offsetWidth * gsap.getProperty(items[length - 1], "scaleX") + (parseFloat(config.paddingRight) || 0), totalWidth, curX, distanceToStart, distanceToLoop, item, i; populateWidths(); gsap.set(items, { xPercent: (i) => xPercents[i], }); gsap.set(items, { x: 0, }); totalWidth = getTotalWidth(); for (i = 0; i < length; i++) { item = items[i]; curX = (xPercents[i] / 100) * widths[i]; distanceToStart = item.offsetLeft + curX - startX; distanceToLoop = distanceToStart + widths[i] * gsap.getProperty(item, "scaleX"); tl.to( item, { xPercent: Math.round( snap(((curX - distanceToLoop) / widths[i]) * 100), ), duration: distanceToLoop / pixelsPerSecond, }, 0, ) .fromTo( item, { xPercent: Math.round( snap( ((curX - distanceToLoop + totalWidth) / widths[i]) * 100, ), ), }, { xPercent: Math.round(xPercents[i]), duration: (curX - distanceToLoop + totalWidth - curX) / pixelsPerSecond, immediateRender: false, }, distanceToLoop / pixelsPerSecond, ) .add("label" + i, distanceToStart / pixelsPerSecond); times[i] = distanceToStart / pixelsPerSecond; } function toIndex(index, vars) { vars = vars || {}; Math.abs(index - curIndex) > length / 2 && (index += index > curIndex ? -length : length); let newIndex = gsap.utils.wrap(0, length, index), time = times[newIndex]; if (time > tl.time() !== index > curIndex) { vars.modifiers = { time: gsap.utils.wrap(0, tl.duration()), }; time += tl.duration() * (index > curIndex ? 1 : -1); } curIndex = newIndex; vars.overwrite = true; return tl.tweenTo(time, vars); } tl.next = (vars) => toIndex(curIndex + 1, vars); tl.previous = (vars) => toIndex(curIndex - 1, vars); tl.current = () => curIndex; tl.toIndex = (index, vars) => toIndex(index, vars); tl.updateIndex = () => (curIndex = Math.round(tl.progress() * items.length)); tl.times = times; tl.progress(1, true).progress(0, true); if (config.reversed) { tl.vars.onReverseComplete(); tl.reverse(); } if (config.draggable && typeof Draggable === "function") { let proxy = document.createElement("div"), wrap = gsap.utils.wrap(0, 1), ratio, startProgress, draggable, dragSnap, roundFactor, align = () => tl.progress( wrap(startProgress + (draggable.startX - draggable.x) * ratio), ), syncIndex = () => tl.updateIndex(); typeof InertiaPlugin === "undefined" && console.warn( "InertiaPlugin required for momentum-based scrolling and snapping. https://greensock.com/club", ); draggable = Draggable.create(proxy, { trigger: items[0].parentNode, type: "x", onPress() { startProgress = tl.progress(); tl.progress(0); populateWidths(); totalWidth = getTotalWidth(); ratio = 1 / totalWidth; dragSnap = totalWidth / items.length; roundFactor = Math.pow( 10, ((dragSnap + "").split(".")[1] || "").length, ); tl.progress(startProgress); }, onDrag: align, onThrowUpdate: align, inertia: true, snap: (value) => { let n = Math.round(parseFloat(value) / dragSnap) * dragSnap * roundFactor; return (n - (n % 1)) / roundFactor; }, onRelease: syncIndex, onThrowComplete: () => gsap.set(proxy, { x: 0, }) && syncIndex(), })[0]; tl.draggable = draggable; } return tl; } // ひよこアニメーション用関数 setTimeout(function() { $('.hiyoko').removeClass('is-scroll'); }, 1000); function timeout() { const target = $('.hiyoko'); target.addClass('is-scroll'); // 一定時間後に削除(例:2秒後) setTimeout(function() { target.removeClass('is-scroll'); }, 1000); } function back() { const target = $('.hiyoko'); if (!target.hasClass('is-back')) { target.addClass('is-back'); } } function forward() { const target = $('.hiyoko'); if (target.hasClass('is-back')) { target.removeClass('is-back'); } } }); |
それぞれ段階を踏んで細かく説明していきます。
HTML
要素は以下の3つです。
ループさせるスライド部分
スライドを動かすための前へ次へボタン
ヒツジの画像
前提として今回のデモのデザインは横幅1400pxで作成しています。
ループする背景を作成したら、途切れないように1400pxずつ書き出し、flexで横並びに配置しています。
この時、横並びがはみ出しても問題ないように、overflow: hidden;をかけておきます。

均等に分割できる横幅でループ背景を作成している場合は問題ありませんが、今回のデモで作成した背景は1400pxずつで書き出すと最後のスライドのみ横幅が足りなくなるので小さくなります。
その場合はサイズは画像に合わせて小さく書き出して下さい。
こちらはCSSで配置する際に他のスライドと高さを合わせます。
CSS
横並びの指定のほか、スライドの大きさ調整をCSSで行っています。
今回は画面幅いっぱいに広がるように設定しているため、横幅のサイズが違う最後のスライドが広がりすぎないよう
それぞれのスライドの最大幅を指定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | .home .slider-wrap .slide:not(:nth-child(3)) { min-width: 100vw; } .home .slider-wrap .slide:nth-child(3) { min-width: 42.8571428571vw; } .home .slider-wrap .slide img { display: block; max-width: calc(100% + 4px); width: calc(100% + 4px); height: 100vh; max-height: 100vh; } |
デモでは画面幅の可変に対応するため、スライドの最大幅をvwで指定しています。
また、スライド間に白い線が出てしまうことがあるので予防として横幅に+4px追加しています。
jQuery
全体を大きく分けると以下の三つの記述があります。
スライダーを初期化する部分
ループアニメーションを作る部分
ヒツジアニメーション用の部分
最後のヒツジのアニメーションはループスライドに直接関係はありませんので、詳しい説明は省きます。
スライダーを初期化する部分
まず、スライダーを「準備・起動」する関数です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | const initSlider = ( wrapperSelector, boxSelector, ) => { const $wrapper = $(wrapperSelector); if ($wrapper.length === 0) return; // 要素がない場合は処理しない const boxes = gsap.utils.toArray(boxSelector); if (boxes.length === 0) return; // ボックスがない場合は処理しない const loop = horizontalLoop(boxes, { // ループ paused: true, draggable: true, speed: 1, repeat: -1, paddingRight: 0, }); // next/prevボタン $(".next").on("click", () => { const totalWidth = boxes.reduce( (sum, box) => sum + box.offsetWidth, 0, ); const duration = loop.duration(); let moveAmount; const averageBoxWidth = totalWidth / boxes.length; moveAmount = ((averageBoxWidth * 0.4) / totalWidth) * duration; gsap.to(loop, { time: "+=" + moveAmount, duration: 0.8, ease: "power1.inOut", modifiers: { time: gsap.utils.wrap(0, duration), }, }); back(); timeout(); }); $(".prev").on("click", () => { const totalWidth = boxes.reduce( (sum, box) => sum + box.offsetWidth, 0, ); const duration = loop.duration(); let moveAmount; const averageBoxWidth = totalWidth / boxes.length; moveAmount = ((averageBoxWidth * 0.4) / totalWidth) * duration; gsap.to(loop, { time: "-=" + moveAmount, duration: 0.8, ease: "power1.inOut", modifiers: { time: gsap.utils.wrap(0, duration), }, }); forward(); timeout(); }); }; |
1 2 3 4 5 6 7 | const $wrapper = $(wrapperSelector); if ($wrapper.length === 0) return; // 要素がない場合は処理しない const boxes = gsap.utils.toArray(boxSelector); if (boxes.length === 0) return; // ボックスがない場合は処理しない |
スライダーを入れる「箱(wrapper)」がページ上に存在するか確認しています。
存在しない場合は処理をやめます。(※エラー防止)
1 2 3 4 5 6 7 8 9 10 | const loop = horizontalLoop(boxes, { // ループ paused: true, draggable: true, speed: 1, repeat: -1, paddingRight: 0, }); |
スライドをループさせる仕組みをloopという名前で設定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | // next/prevボタン $(".next").on("click", () => { const totalWidth = boxes.reduce( (sum, box) => sum + box.offsetWidth, 0, ); const duration = loop.duration(); let moveAmount; const averageBoxWidth = totalWidth / boxes.length; moveAmount = ((averageBoxWidth * 0.4) / totalWidth) * duration; gsap.to(loop, { time: "+=" + moveAmount, duration: 0.8, ease: "power1.inOut", modifiers: { time: gsap.utils.wrap(0, duration), }, }); back(); timeout(); }); $(".prev").on("click", () => { const totalWidth = boxes.reduce( (sum, box) => sum + box.offsetWidth, 0, ); const duration = loop.duration(); let moveAmount; const averageBoxWidth = totalWidth / boxes.length; moveAmount = ((averageBoxWidth * 0.4) / totalWidth) * duration; gsap.to(loop, { time: "-=" + moveAmount, duration: 0.8, ease: "power1.inOut", modifiers: { time: gsap.utils.wrap(0, duration), }, }); forward(); timeout(); }); |
前へ次へボタンの挙動の記述です。
『const totalWidth = ~』の記述は 全スライドの横幅を合計して計算しています。
(例:スライドが3枚あって各300pxなら合計900px)
1 2 3 4 5 | let moveAmount; const averageBoxWidth = totalWidth / boxes.length; moveAmount = ((averageBoxWidth * 0.4) / totalWidth) * duration; |
1回のクリックでどのくらい動かすかを計算しています。
デモではスライド1枚の平均幅の40%分だけ動かしています。
サイトに合わせて変えたい場合は『0.4』を変えてください。
1 2 3 4 5 6 7 8 9 10 | gsap.to(loop, { time: "+=" + moveAmount, duration: 0.8, ease: "power1.inOut", modifiers: { time: gsap.utils.wrap(0, duration), }, }); |
スライダーのアニメーションを実行しています。
ease: “power1.inOut”は、電車のようにゆっくり加速してゆっくり止まる動きです。
他の数値はGSAPの公式サイトをご覧ください。
注意点として、前へと次への記述で『time: 』に設定する数値が微妙に違うので気を付けてください。
ループアニメーションを作る部分
こちらがループスライダーを心臓部分となります。
スライドが端まで来たら反対側に瞬間移動して「無限ループしているように見せる」仕組みとなります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | populateWidths = () => items.forEach((el, i) => { // Math.roundではなくMath.ceilで切り上げ widths[i] = Math.ceil( parseFloat(gsap.getProperty(el, "width", "px")), ); xPercents[i] = Math.floor( snap( (parseFloat(gsap.getProperty(el, "x", "px")) / widths[i]) * 100 + gsap.getProperty(el, "xPercent"), ), ); }), |
各スライドの「幅」と「現在位置(%)」を測って記録しています。
後で正確に動かすために必要な準備です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | for (i = 0; i < length; i++) { item = items[i]; curX = (xPercents[i] / 100) * widths[i]; distanceToStart = item.offsetLeft + curX - startX; distanceToLoop = distanceToStart + widths[i] * gsap.getProperty(item, "scaleX"); tl.to( item, { xPercent: Math.round( snap(((curX - distanceToLoop) / widths[i]) * 100), ), duration: distanceToLoop / pixelsPerSecond, }, 0, ) .fromTo( item, { xPercent: Math.round( snap( ((curX - distanceToLoop + totalWidth) / widths[i]) * 100, ), ), }, { xPercent: Math.round(xPercents[i]), duration: (curX - distanceToLoop + totalWidth - curX) / pixelsPerSecond, immediateRender: false, }, distanceToLoop / pixelsPerSecond, ) .add("label" + i, distanceToStart / pixelsPerSecond); times[i] = distanceToStart / pixelsPerSecond; } |
各スライドに対して2種類の動きを設定しています。
.to()は画面の左端から外れるまで移動する動き、
.fromTo()は右端の外側から登場する動きです。
これを組み合わせることで「消えたスライドが反対側から来る」無限ループが完成します。
完成
プラグインを使わない無限ループスライダーの完成です。
無限ループスライダー
まとめ
プラグインを使わず少しずつ動く無限ループスライダーの実装方法をご紹介しました。
プラグインを使用したスライダーはスライド1枚分ずつしか動かせないという悩みを解消できるのが、この手法の一番の魅力です。
今回はヒツジが歩いているようなデザインでしたが、他にも車や人などに変えるとより、サイトに合うものになると思います。
ぜひ今回の実装を参考に、オリジナルのスライダーを作ってみてください!
