Instagram-Like Pagination with Framer Motion
Have you ever noticed Instagram’s Feed Pagination? Probably not. It’s just there, doing its job, and doing it right. It’s so subtle you don’t notice it. As you swipe through photos, the pagination dots slide smoothly, scale elegantly and provide just the right amount of context.
I’ve spent some time reverse-engineering this effect, and I’m excited to share how you can implement it in your own React applications using Framer Motion.
The finished component will look like this:
Pretty slick! But here’s the really cool part: this pagination can handle an infinite number of images while only rendering the dots you can actually see. No matter if you have 10 images or 10,000, the DOM will only contain ~5-7 dot elements at any given time.
Let’s break down how it works.
A sliding window
Imagine looking through a narrow window, behind that window is a (possibly very long) strip of dots. You can only see 5 dots at a time through the window (configured via VISIBLE_DOTS), but there might be dozens or hundreds of dots on that strip.
const VISIBLE_DOTS = 5 // How many dots to show at once
const DOT_SIZE = 12 // Size of the dot in px
const DOT_GAP = 8 // Gap between dots in px
const DOT_WIDTH = DOT_SIZE + DOT_GAP // Total width per unit (20px) The magic happens with a windowStart state variable that tracks which dot is at the beginning of our visible window:
const [windowStart, setWindowStart] = React.useState(0) If windowStart is 0, we’re showing dots 0-4. If it’s 3, we’re showing dots 3-7. Makes sense, right?
Moving the window
We want the window to slide left and right (as opposed to just jumping) as the user navigates through the images. This will create a smoother experience. This is handled in a useEffect that watches the current slide index:
React.useEffect(() => {
const windowEnd = windowStart + VISIBLE_DOTS - 1
const threshold = 2 // How close to edge before scrolling
if (current >= total - 1 && windowStart !== total - VISIBLE_DOTS) {
// Jump to end if we hit the very last item
setWindowStart(total - VISIBLE_DOTS)
} else if (current === 0 && windowStart !== 0) {
// Jump to start if we hit the first item
setWindowStart(0)
} else if (current > windowEnd - threshold) {
// Shift Right
const newStart = Math.min(total - VISIBLE_DOTS, current - VISIBLE_DOTS + threshold + 1)
setWindowStart(newStart)
} else if (current < windowStart + threshold) {
// Shift Left
const newStart = Math.max(0, current - threshold)
setWindowStart(newStart)
}
}, [current, windowStart, total]) Let’s walk through each case:
Case 1: Hitting the Edges
if (current >= total - 1 && windowStart !== total - VISIBLE_DOTS) {
// Jump to end if we hit the very last item
setWindowStart(total - VISIBLE_DOTS)
} else if (current === 0 && windowStart !== 0) {
// Jump to start if we hit the first item
setWindowStart(0)
} If the user jumps to the very first or very last slide (maybe by clicking a dot), we immediately snap the window to show the beginning or end. This prevents awkward situations where you’re on the last slide but can’t see its dot.
Case 2: Sliding Right
else if (current > windowEnd - threshold) {
const newStart = Math.min(total - VISIBLE_DOTS, current - VISIBLE_DOTS + threshold + 1)
setWindowStart(newStart)
} The threshold value (set to 2) determines how close to the edge the active dot can get before the window slides. When you’re at position windowEnd - 2 (the 3rd dot from the right edge), advancing one more slide triggers the window to shift right.
Why not wait until the very edge? Because it feels more natural to see dots sliding in before you reach them, giving you a preview of what’s coming.
Case 3: Sliding Left
else if (current < windowStart + threshold) {
const newStart = Math.max(0, current - threshold)
setWindowStart(newStart)
} Same idea, but in reverse. When you’re getting close to the left edge of the window, we shift left to maintain that comfortable buffer zone.
The Animation: Smooth Sliding
With our windowStart updating, we need to actually move the dots. This is where Framer Motion shines:
<motion.div
className="relative flex"
animate={{ x: -windowStart * DOT_WIDTH }}
transition={{ type: "spring", stiffness: 200, damping: 30 }}
style={{ width: total * DOT_WIDTH, height: 20 }}
layout
> The key is animate={{ x: -windowStart * DOT_WIDTH }}. Each dot is 20px wide (12px dot + 8px gap), so when windowStart changes from 0 to 1, the entire container slides left by 20px. When it changes to 2, it slides left by 40px. And so on.
The spring transition (stiffness: 200, damping: 30) gives it a slightly bouncy feel. Try tweaking these values—higher stiffness makes it snappier, lower damping makes it bouncier.
The Efficiency Trick: Conditional Rendering
Here’s the part that makes this scale beautifully. For each dot, we calculate a scale value based on its position relative to the window:
let scale = 1
if (index < windowStart) {
scale = 0 // Hidden, left of window
} else if (index === windowStart) {
scale = windowStart === 0 ? 1 : 0.5 // First visible dot
} else if (index === windowStart + 1) {
scale = windowStart === 0 ? 1 : 0.75 // Second visible dot
} else if (index === windowStart + VISIBLE_DOTS - 2) {
scale = windowStart === total - VISIBLE_DOTS ? 1 : 0.75 // Second-to-last visible
} else if (index === windowStart + VISIBLE_DOTS - 1) {
scale = windowStart === total - VISIBLE_DOTS ? 1 : 0.5 // Last visible dot
} else if (index >= windowStart + VISIBLE_DOTS) {
scale = 0 // Hidden, right of window
} Notice the pattern? Dots in the middle of the window are full size (scale = 1). Dots at the edges scale down to 0.75, then 0.5, creating a nice “peeking” effect that hints at more content. Dots outside the window are set to scale = 0.
And here’s the clever bit:
return (
<AnimatePresence mode="popLayout">
{scale !== 0 && (
<motion.div
initial={false}
layout
key={index}
// ... motion.button inside
/>
)}
</AnimatePresence>
) We only render dots that have scale !== 0. This means dots far to the left or right of the window don’t exist in the DOM at all. They’re not hidden with CSS—they’re literally not there.
When a dot needs to appear (because the window shifted), Framer Motion’s AnimatePresence handles the enter animation. When it needs to disappear, it handles the exit animation. This is why we see smooth fading and scaling as dots come and go.
So if you have 1,000 images, the DOM only contains maybe 5-7 button elements. That’s the efficiency win.
The Button Animation: Scale & Color
Each individual dot button animates its own properties:
<motion.button
onClick={() => onChange(index)}
initial={false}
animate={{
scale: scale,
opacity: scale === 0 ? 0 : 1,
backgroundColor: current === index ? "#ffffff" : "rgba(255, 255, 255, 0.4)",
}}
transition={{ duration: 0.2 }}
className="rounded-full focus:outline-none"
style={{ width: DOT_SIZE, height: DOT_SIZE }}
/> The active dot is fully white (#ffffff), while inactive ones are semi-transparent. The scale property creates that shrinking effect at the edges. And the opacity ensures dots fade out gracefully when they exit.
Positioning: Absolute Positioning with Transforms
<motion.div
initial={false}
layout
key={index}
className="absolute flex shrink-0 items-center justify-center"
style={{
x: index * DOT_WIDTH,
width: DOT_WIDTH,
height: 20,
}}
> The x: index * DOT_WIDTH positions each dot at its “natural” position on the strip (dot 0 at 0px, dot 1 at 20px, dot 2 at 40px, etc.). The parent container then slides left/right to bring the correct section into view.
This approach keeps the positioning logic simple — each dot just needs to know its index — while the parent handles the sliding window movement.
Bringing It All Together
Let’s go through what happens when you swipe from image 0 to image 3:
- Image 0 → 1:
windowStartstays at 0. Window shows dots 0-4. No sliding. - Image 1 → 2: Still within threshold. Window stays at 0-4.
- Image 2 → 3: We’re now at
windowEnd - 2. Window slides to show dots 1-5. The parent container animates tox: -20px. Dot 0 starts shrinking and eventually unmounts. Dot 5 fades in from the right. - Image 3 → 2: Going back. Window stays at 1-5 (threshold applies).
- Image 2 → 1: We’re getting close to the left edge. Window slides back to 0-4. Dot 0 fades back in.
The beauty is that all of this happens automatically. The window logic, the scaling, the DOM mounting/unmounting—it all flows from those few state changes.
Why This Matters
You might be thinking: “This seems like a lot of work for pagination!” And you’d be right—if you only have 5 images, this is overkill.
But when you’re building a product that might scale — maybe user-generated galleries, property listings, or product catalogs — performance matters. Rendering 100 DOM nodes for pagination when you only see 5 is wasteful. This pattern scales beautifully without any performance cliffs.
Plus, the interaction just feels better. That sliding motion, the peeking dots at the edges, the smooth scaling—it adds up to something that feels crafted and intentional.
Try It Yourself
You can find the complete source code for this Carousel component on CodeSandbox . Try playing with the constants:
- Change
VISIBLE_DOTSto 8 or 12 - Adjust the spring
stiffnessanddamping - Modify the
thresholdvalue - Tweak the scale values for different edge effects
Each change will give you a different feel. There’s no “perfect” configuration—it depends on your design and the experience you want to create.
Now go build something delightful! ✨