How does Nike make these buttery smooth carousels on their website with pure CSS?
Let me show you how I quickly recreated this and how the code works behind it:
First, we need some markup
The first thing we are going to need is some markup that we can add the scroll behavior to.
Instead of hand coding all of it, there is an easier way. I used the Builder.io Chrome extension to copy the layout right to my clipboard to recreate it.
Builder is connected to an existing site I have, using my design system and all that good stuff, but you can generate net new ones too.
Now that we have some baseline markup, lets jump into the quick and easy way to get what we want.
The magic words
There are two secrets you need: need CSS scroll snapping
, and a method called scrollIntoView
in JavaScript.
The easiest way to do this is just to prompt an LLM. In Builder.io I just selected the section I imported and typed the prompt:
add CSS scroll snapping, and add arrow buttons using scrollIntoView()
Now, using our magic words, we have beautiful snapping scroll that also moves one card at a time with arrows, just like Nike. On mobile, it behaves exactly how we want too.
Why this works at a glance
- CSS
scroll-snap
gives us native, hardware accelerated scrolling and snapping scrollIntoView()
cooperates with snapping and respectsscroll-padding
- No scroll jacking, no custom wheel handlers, just smooth native behavior
The CSS, step by step
Let’s now break down the code. We’ll separate the container and the cards so the role of each line is clear.
Scrolling container
.nike-carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding: 0 18px;
}
display: flex
lays the cards in a horizontal rowoverflow-x: auto
enables native horizontal scrollingscroll-snap-type: x mandatory
locks the final position to snap points on the X axisscroll-padding: 0 18px
offsets the snap target from the edges so cards do not hug the bezel
Cards
.nike-product-card {
flex-shrink: 0;
scroll-snap-align: start;
}
flex-shrink: 0
prevents width compression so snapping math stays stablescroll-snap-align: start
makes each card the snap target relative to the container start. You can trycenter
orend
for a different feel, butstart
plus padding matches Nike’s look
Add buttons to scroll the carousel with native JS
Now, if you want to add arrows to go to the next or previous items, we can use a handy method in JavaScript called scrollIntoView()
We host left and right arrows, the scrollable list, and keep button states in sync with scroll. I will show the full shape first, then fill in the scroll function.
import React, { useRef, useState, useEffect } from "react";
function ScrollableCarousel({ cards }) {
const scrollRef = useRef<HTMLUListElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const checkScrollButtons = () => {
/* filled in below */
};
const scrollByOne = (dir: "left" | "right") => {
/* filled in below */
};
return (
<div className="carousel-container">
<LeftArrow onClick={() => scrollByOne("left")} disabled={!canScrollLeft} />
<RightArrow onClick={() => scrollByOne("right")} disabled={!canScrollRight} />
<ul
ref={scrollRef}
onScroll={checkScrollButtons}
className="nike-carousel"
>
{cards.map((card, i) => (
<Card card={card} key={1} />
))}
</ul>
</div>
);
}
What the shell is doing: scrollRef
lets us read scroll position and children. Button states come from scrollLeft
, scrollWidth
, and clientWidth
, with a tiny 5px grace on each edge so the buttons disable cleanly. onScroll
keeps states updated during drag or momentum scrolling.
The scroll function using scrollIntoView()
This is the simple way that cooperates with snapping and respects your scroll-padding
.
const scrollByOne = (dir: "left" | "right") => {
const el = scrollRef.current;
if (!el) return;
const children = Array.from(el.children) as HTMLElement[];
if (!children.length) return;
const cardWidth = children[0].offsetWidth || 1;
const currentIndex = Math.round(el.scrollLeft / cardWidth);
const targetIndex =
dir === "left"
? Math.max(0, currentIndex - 1)
: Math.min(children.length - 1, currentIndex + 1);
children[targetIndex].scrollIntoView({
behavior: "smooth",
block: "nearest", // do not move vertically
inline: "start", // align to container start, honors scroll-padding
});
};
Why I like this: we let the browser do the heavy lifting. Snap alignment stays consistent whether the user swipes, wheels, or clicks the arrows.
Check the scroll position with a scroll listener
We need to enable or disable the left and right arrows based on where the user is in the scroll area. checkScrollButtons
reads three native properties from the scroll container and sets two booleans.
const checkScrollButtons = () => {
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
// small grace so the buttons flip cleanly
setCanScrollLeft(scrollLeft > 5);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 5);
};
What each value means
scrollLeft
is how many pixels the container is scrolled from the left.
scrollWidth
is the total scrollable content width, including offscreen content.
clientWidth
is the visible width of the container.
Add Variations
Once we have our code in place, we can use AI to play with variations. I like to use Builder.io to give me a visual canvas to rapidly experiment with and get a feel for each option.
For instance, I experimented with shifting more than one card at a time on scroll, with the prompt:
When I click any of the scroll arrow buttons scroll by 2 instead of 1
After trying that, I quite liked it. Felt like I was moving more like a page at a time like a normal carousel than just one tiny card at a time (I want to see more than just one more card over!)
Design the UX visually, keep shipping real PRs
Now that this is reproduced in Builder.io, we have an AI canvas to experiment with modifications. I can say: always scroll by two instead of one when I click these buttons, and see the feel instantly. This kind of visual IDE connected to your code lets you rapidly test what feels best.
The best part here with Builder is we are editing live production code in a branch. Designers can make UX tweaks directly in the visual canvas, then click Send PR.
Engineering gets a clean, well formatted pull request. If you want anything different, you can leave comments tagging the Builder.io bot. Say move this to a new file or extract the scroll logic into a hook.
The bot replies and pushes updates until you are ready to merge. This removes red lines and back and forth so engineers do not have to chase tiny alignment details and designers are not locked to static mocks.
Try Builder.io out for free and lmk your feedback!