Logo

Swiper v8 to v11: The Loop Mode Nightmare

8 min read
SwiperReactNext.js

Table of Contents

The Symptom

After upgrading Swiper from v8 to v11 alongside a React 19 / Next.js 15 migration, certain pages started violently scrolling back and forth. Not a subtle jank — the page was jumping 1,000+ pixels per frame, making it completely unusable.

The browser console was flooded with this warning:

1Swiper Loop Warning: The number of slides is not enough for loop mode,
2it will be disabled or not function properly. You need to add more slides
3(or make duplicates) or lower the values of slidesPerView and slidesPerGroup parameters

Firing infinitely. From resizeHandler.

Finding the Root Cause

The affected carousels had a common pattern: loop: true with few slides. One carousel had 2 items, another had 4. They all used slidesPerView: 'auto' with centeredSlides: true — a standard config for centered carousels where adjacent slides peek from the edges.

In Swiper v8, this worked perfectly. In v11, it caused an infinite resize loop.

What Changed Between v8 and v11

The answer is in v9's changelog: loop mode was completely rewritten.

In v8, loop worked by cloning DOM nodes. Swiper would duplicate your slides and place copies at the start and end of the carousel. Simple, brute-force, and it worked with any number of slides.

Starting in v9, loop works by rearranging the actual slides using CSS transforms. No more DOM cloning. Swiper physically moves slides from one end to the other to create the illusion of infinite scroll.

This was a good architectural change — the old clone-based approach had real problems:

But the new approach introduced a hard requirement: you need enough slides to fill both sides of the viewport. Specifically, slidesPerView + loopedSlides slides minimum. If Swiper can't find a slide to move into the empty space, it breaks.

The Infinite Resize Loop

Here's what happens when the requirement isn't met:

  1. Swiper initializes with loop: true
  2. loopFix() detects there aren't enough slides
  3. loopFix() attempts to rearrange slides anyway, modifying the DOM
  4. The DOM change triggers the ResizeObserver
  5. resizeHandler fires and calls loopFix() again
  6. Go to step 2

Each iteration causes a layout recalculation, and the browser compensates by adjusting the scroll position. The result: the page bounces back and forth until the tab crashes or the user navigates away.

How Many Slides Do You Actually Need?

It depends on your config. The formula from Swiper's source:

1// swiper-core.mjs, inside loopFix()
2let slidesPerView = params.slidesPerView
3if (slidesPerView === 'auto') {
4 slidesPerView = swiper.slidesPerViewDynamic()
5}
6
7let loopedSlides = centeredSlides
8 ? Math.max(slidesPerGroup, Math.ceil(slidesPerView / 2))
9 : slidesPerGroup
10loopedSlides += params.loopAdditionalSlides
11
12// The check:
13if (slides.length < slidesPerView + loopedSlides) {
14 showWarning('...')
15}

For a carousel where slide width equals container width (1 slide visible):

For a wider carousel showing 3 slides:

The Red Herrings

Before finding the real fix, I went through a few approaches that didn't work.

loopAddBlankSlides: true

This sounded perfect — Swiper v11 has a parameter that adds blank slides when there aren't enough. It even defaults to true. Surely this solves the problem?

It doesn't. Looking at the source:

1// swiper-core.mjs
2if (params.loopAddBlankSlides && (params.slidesPerGroup > 1 || gridEnabled)) {
3 // Only executes when slidesPerGroup > 1 or grid is enabled
4 addBlankSlides(slidesToAdd)
5}

loopAddBlankSlides only fires when slidesPerGroup > 1 or CSS grid mode is on. With the default slidesPerGroup: 1, it's completely inert. The parameter exists to pad group-based pagination, not to fix the minimum slide count for loop mode.

The loop warning comes from a completely separate check in loopFix() that runs after the blank slide logic. These are two different code paths solving two different problems.

loopAdditionalSlides

This increases loopedSlides in the formula above. But loopedSlides is part of the requirement, not the supply. Setting loopAdditionalSlides: 3 doesn't add 3 slides — it increases the minimum needed by 3. It makes the problem worse.

cssMode: true

Uses native CSS scroll-snap instead of JavaScript transforms. The loop still triggers the same resize warning and thrashing. CSS mode changes how slides are positioned, not how loop calculates its requirements.

rewind: true Instead of loop: true

This actually works — rewind makes the carousel animate back to the first slide when you reach the end. No minimum slide requirement. But there's a problem:

For carousels with centeredSlides: true that show adjacent slides with lower opacity (a common design pattern), rewind doesn't show the previous slide to the left of the first slide. With loop, slide 3 wraps around to appear on the left. With rewind, there's just empty space. This broke the design on many pages.

The Actual Fix

The fix is to do what Swiper v8 did internally: duplicate the slides. But instead of patching every carousel in the codebase, I put the logic in a single place.

In our codebase, every carousel imports Swiper from a shared module. Instead of re-exporting Swiper directly, I wrapped it:

1import { Swiper as SwiperOriginal, SwiperSlide } from 'swiper/react'
2
3const LOOP_DUPLICATE_THRESHOLD = 6
4
5const isSwiperSlide = (child) =>
6 typeof child === 'object' &&
7 'type' in child &&
8 child.type?.displayName === 'SwiperSlide'
9
10const Swiper = ({ children, ...props }) => {
11 const childArray = Children.toArray(children)
12 const slides = childArray.filter(isSwiperSlide)
13 const nonSlides = childArray.filter((c) => !isSwiperSlide(c))
14 const slideCount = slides.length
15
16 // Enough slides → pass through unchanged
17 if (
18 !props.loop ||
19 slideCount === 0 ||
20 slideCount >= LOOP_DUPLICATE_THRESHOLD
21 ) {
22 return <SwiperOriginal {...props}>{children}</SwiperOriginal>
23 }
24
25 // Not enough slides → duplicate to meet loop requirement
26 const copies = Math.ceil(LOOP_DUPLICATE_THRESHOLD / slideCount)
27 const duplicatedSlides = Array.from({ length: copies + 1 }, (_, i) =>
28 slides.map((slide, j) => cloneElement(slide, { key: `loop-${i}-${j}` })),
29 ).flat()
30
31 return (
32 <SwiperOriginal
33 {...props}
34 onPaginationUpdate={(swiper, paginationEl) => {
35 // Hide extra pagination bullets
36 const bullets = swiper.pagination?.bullets
37 if (!bullets) return
38 const mappedIndex = swiper.realIndex % slideCount
39 for (let i = 0; i < bullets.length; i++) {
40 if (i < slideCount) {
41 bullets[i].style.display = ''
42 bullets[i].classList.toggle(
43 'swiper-pagination-bullet-active',
44 i === mappedIndex,
45 )
46 } else {
47 bullets[i].style.display = 'none'
48 }
49 }
50 }}
51 onSlideChange={(swiper) => {
52 // Sync active bullet to original slide index
53 const bullets = swiper.pagination?.bullets
54 if (!bullets) return
55 const mappedIndex = swiper.realIndex % slideCount
56 for (let i = 0; i < slideCount; i++) {
57 bullets[i]?.classList.toggle(
58 'swiper-pagination-bullet-active',
59 i === mappedIndex,
60 )
61 }
62 }}
63 >
64 {duplicatedSlides}
65 {nonSlides}
66 </SwiperOriginal>
67 )
68}
69
70export { Swiper, SwiperSlide }

How It Works

  1. Count SwiperSlide children using displayName (Swiper React sets this on SwiperSlide)
  2. If enough slides (>= 6): pass everything through to Swiper unchanged. Zero overhead for the common case.
  3. If not enough slides: clone the slides until we have at least 6, then fix pagination:
    • onPaginationUpdate: hide bullets beyond the original count, remap the active state
    • onSlideChange: sync the active bullet to realIndex % originalCount

Non-slide children (navigation, pagination, custom elements rendered inside <Swiper>) are preserved and placed after the duplicated slides.

Why 6?

The threshold of 6 covers the worst case in our codebase: 3 slides visible with centeredSlides (needs 5 minimum). Setting it to 6 gives a buffer. Carousels with 6+ slides never had the loop issue.

What I Learned

A few takeaways from this:

Read the migration guide, but also read the source. The Swiper v11 migration guide mentions loop changes in one line. The actual behavioral change — from DOM cloning to slide rearrangement — is buried in the v9 changelog. The minimum slide requirement isn't documented anywhere I could find; I had to read swiper-core.mjs to understand the formula.

loopAddBlankSlides is misleading. The name suggests it adds blank slides when loop doesn't have enough. It doesn't. It pads slide groups for pagination alignment. The parameter name is reasonable in context, but if you're searching for "how to fix loop with few slides," it's a trap.

Wrapper components are underrated. Instead of patching every carousel call site, wrapping the exported Swiper component in one place fixed the entire codebase. The wrapper is transparent — carousels with enough slides get the original component with zero overhead.

Sometimes the old approach was right. Swiper v9 removed DOM cloning for good reasons (state sync, Virtual Translate compatibility). But for carousels with few slides, cloning was the correct solution. The fix is literally "do what v8 did" — just in React-land instead of Swiper-land.

References

Related Articles