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 slides3(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:
- Cloned slides didn't sync with React state updates
- Virtual Translate was incompatible with loop mode
- The
swiper-slide-duplicateclass caused all sorts of edge cases
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:
- Swiper initializes with
loop: true loopFix()detects there aren't enough slidesloopFix()attempts to rearrange slides anyway, modifying the DOM- The DOM change triggers the
ResizeObserver resizeHandlerfires and callsloopFix()again- 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.slidesPerView3if (slidesPerView === 'auto') {4 slidesPerView = swiper.slidesPerViewDynamic()5}67let loopedSlides = centeredSlides8 ? Math.max(slidesPerGroup, Math.ceil(slidesPerView / 2))9 : slidesPerGroup10loopedSlides += params.loopAdditionalSlides1112// The check:13if (slides.length < slidesPerView + loopedSlides) {14 showWarning('...')15}
For a carousel where slide width equals container width (1 slide visible):
slidesPerView ≈ 1–2(Swiper counts partial visibility)loopedSlides = Math.max(1, Math.ceil(2/2)) = 1- Minimum: 3 slides
For a wider carousel showing 3 slides:
slidesPerView ≈ 3loopedSlides = Math.max(1, Math.ceil(3/2)) = 2- Minimum: 5 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.mjs2if (params.loopAddBlankSlides && (params.slidesPerGroup > 1 || gridEnabled)) {3 // Only executes when slidesPerGroup > 1 or grid is enabled4 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'23const LOOP_DUPLICATE_THRESHOLD = 645const isSwiperSlide = (child) =>6 typeof child === 'object' &&7 'type' in child &&8 child.type?.displayName === 'SwiperSlide'910const 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.length1516 // Enough slides → pass through unchanged17 if (18 !props.loop ||19 slideCount === 0 ||20 slideCount >= LOOP_DUPLICATE_THRESHOLD21 ) {22 return <SwiperOriginal {...props}>{children}</SwiperOriginal>23 }2425 // Not enough slides → duplicate to meet loop requirement26 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()3031 return (32 <SwiperOriginal33 {...props}34 onPaginationUpdate={(swiper, paginationEl) => {35 // Hide extra pagination bullets36 const bullets = swiper.pagination?.bullets37 if (!bullets) return38 const mappedIndex = swiper.realIndex % slideCount39 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 index53 const bullets = swiper.pagination?.bullets54 if (!bullets) return55 const mappedIndex = swiper.realIndex % slideCount56 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}6970export { Swiper, SwiperSlide }
How It Works
- Count SwiperSlide children using
displayName(Swiper React sets this onSwiperSlide) - If enough slides (>= 6): pass everything through to Swiper unchanged. Zero overhead for the common case.
- 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 stateonSlideChange: sync the active bullet torealIndex % 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
- Swiper v11 Migration Guide
- Swiper v9 Migration Guide — where loop mode was rewritten
- Swiper v11 Blog Post
- GitHub Issue #7254 — discussion on loop behavior with few slides
