
This is a vanilla JavaScript and CSS component that allows you to create smooth, infinite-scrolling card carousels with mouse drag, touch swipe, and kinetic scrolling support.
Features:
- Multi-interaction: It works with mouse drag, touch swipe, and shift + mouse wheel for scrolling.
- Kinetic scrolling: Implements momentum-based scrolling that feels natural and responsive to user interactions.
- True infinite scroll: Uses content duplication to create genuine endless scrolling without visible boundaries or reset points.
- CSS custom properties: Theming support with automatic dark mode detection and customizable color schemes.
- Performance optimization: Uses RequestAnimationFrame for smooth animations.
How to use it:
1. Create the HTML for the carousel. The structure is a main .carousel container, a .carousel-content wrapper inside it, and then your individual .card elements.
<div class="carousel">
<div class="carousel-content">
<div class="card">
<img class="icon" src="javascript.svg" alt="" />
<p>JavaScript</p>
<p><a href="#" target="_blank">More Info</a></p>
</div>
<div class="card">
<img class="icon" src="cplusplus.svg" alt="" />
<p>C++</p>
<p><a href="#" target="_blank">More Info</a></p>
</div>
... more cards here
</div>
</div>2. Add the necessary CSS. The styles handle the flexbox layout, hide the scrollbar, and create the faded-edge effect using mask-image.
:root {
--bg-primary-color: #eee;
--bg-secondary-color: #ddd;
--card-color: #fff;
--card-border-color: #aaa;
--card-hover-border-color: #fa0;
--card-hover-glow-color: #fa07;
--card-shadow-color: #0002;
--link-color: #f80;
--link-visited-color: #e70;
--link-hover-color: #fb7;
--link-focus-color: #fa4;
--footer-text-color: #aaa;
}
/* Adjust Colors for Dark Themes */
@media screen and (prefers-color-scheme: dark) {
:root {
--bg-primary-color: #141414;
--bg-secondary-color: #1f1f1f;
--card-color: #3b3b3b;
--card-border-color: #292929;
--card-shadow-color: #0009;
--footer-text-color: #777;
}
body {
color: #fff;
}
.icon {
filter: invert();
}
}
body {
margin: 0;
padding: 3rem 0;
font-family: "Quicksand", sans-serif;
background-color: #eee;
background: repeating-linear-gradient(
-45deg,
var(--bg-primary-color) 0% 4%,
#0000 4% 5%
),
repeating-linear-gradient(
45deg,
var(--bg-primary-color) 0% 4%,
var(--bg-secondary-color) 4% 5%
);
background-color: var(--bg-primary-color);
}
h1 {
margin: 12px 0;
text-align: center;
}
p {
margin: 12px 0;
}
a {
color: var(--link-color);
font-weight: bold;
transition: color 0.15s;
}
a:visited {
color: var(--link-visited-color);
}
a:focus {
color: var(--link-focus-color);
outline: none;
}
a:hover {
color: var(--link-hover-color);
}
a:active {
color: var(--link-visited-color);
}
.carousel {
display: flex;
gap: 1rem;
box-sizing: border-box;
width: 100%;
max-width: 1000px;
margin: 0 auto;
padding: 1rem;
/* Hide the overflow so we can program scrolling. */
overflow-x: hidden;
mask-image: linear-gradient(
90deg,
#0000 0% 1%,
#0004 1% 2%,
#000b 2% 4%,
#000 4% 96%,
#000b 96% 98%,
#0004 98% 99%,
#0000 99% 100%
);
}
.carousel:focus {
/* Get rid of a random focus outline. */
outline: none;
}
.carousel:hover {
cursor: grab;
}
.carousel:active {
cursor: grabbing;
}
.carousel-content {
display: flex;
gap: 1rem;
}
.card {
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
width: 200px;
height: 270px;
background-color: var(--card-color);
border: 2px solid var(--card-border-color);
border-radius: 1rem;
box-shadow: 0 2px 0 2px var(--card-shadow-color), 0 0 0 3px #0000;
font-size: 1.5rem;
text-align: center;
-webkit-user-select: none;
user-select: none;
transition: border 0.667s, box-shadow 0.333s;
}
.card:hover,
.card:focus-within {
border: 2px solid var(--card-hover-border-color);
box-shadow: 2px 4px 0 #0000, 0 0 0 6px var(--card-hover-glow-color);
}
.icon {
display: block;
height: 120px;
pointer-events: none;
}3. Include the JavaScript. The script waits for the DOMContentLoaded event, finds the carousel, and sets everything up. It handles all the event listeners for dragging and scrolling. One thing to note is the carouselDuplicates constant at the top. This determines how many copies of your card set are created to produce the infinite effect. The default of 3 is usually sufficient.
const carouselDuplicates = 3;
function lerp(a, b, t) {
return a + (b - a) * t;
}
function getTouchMidpoint(touches) {
let midpoint = {
x: touches[0].clientX,
y: touches[0].clientY
};
for (let i = 1; i < touches.length; i++) {
midpoint.x = lerp(midpoint.x, touch.clientX, 0.5);
midpoint.y = lerp(midpoint.y, touch.clientY, 0.5);
}
return midpoint;
}
document.addEventListener("DOMContentLoaded", () => {
const carousel = document.querySelector(".carousel");
const carouselContent = carousel.querySelector(".carousel-content");
const prefersReducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const hasFinePointer = matchMedia("(pointer: fine)");
let carouselHasMouse = false;
let carouselTouches = 0;
let lastMouseX = null;
let lastTouchX = null;
let scrollDelta = 0;
let lastTimestamp = 0;
// An event handler for handling touchend and touchcancel.
const handleTouchRemove = (event) => {
carouselTouches -= event.changedTouches.length;
if (carouselTouches <= 0 && !carouselHasMouse) {
lastTouchX = null;
}
};
// Calling this with requestAnimationFrame will start the update loop.
const updateScroll = (timestamp) => {
const deltaTime = timestamp - lastTimestamp;
carousel.scrollBy({
left: scrollDelta
});
if (carouselHasMouse || carouselTouches > 0 || prefersReducedMotion.matches) {
scrollDelta = 0;
} else {
scrollDelta = lerp(scrollDelta, 0, 0.045);
}
lastTimestamp = timestamp;
requestAnimationFrame(updateScroll);
};
// Handle mouse input.
carousel.addEventListener("mousedown", (event) => {
carouselHasMouse = true;
});
window.addEventListener("mouseup", (event) => {
carouselHasMouse = false;
lastMouseX = null;
});
window.addEventListener("mousemove", (event) => {
if (carouselHasMouse) {
if (lastMouseX !== null) {
scrollDelta = lastMouseX - event.x;
}
lastMouseX = event.x;
}
});
carousel.addEventListener("wheel", (event) => {
if (hasFinePointer.matches && event.shiftKey) {
event.preventDefault();
const scrollMultiplier = prefersReducedMotion.matches ? 2 : 0.1;
scrollDelta += event.deltaY * scrollMultiplier;
}
});
// Handle touch input.
carousel.addEventListener("touchstart", (event) => {
if (lastTouchX === null) {
lastTouchX = getTouchMidpoint(event.touches).x;
}
carouselTouches += event.changedTouches.length;
});
window.addEventListener("touchmove", (event) => {
if (lastTouchX !== null) {
const touchMidpoint = getTouchMidpoint(event.touches);
scrollDelta = -(touchMidpoint.x - lastTouchX);
lastTouchX = touchMidpoint.x;
}
});
window.addEventListener("touchend", handleTouchRemove);
window.addEventListener("touchcancel", handleTouchRemove);
// This is where the infinite scrolling logic comes to play.
carousel.addEventListener("scroll", (event) => {
const carouselRect = carouselContent.getBoundingClientRect();
if (carouselRect.left > window.innerWidth) {
carousel.scrollLeft += carouselRect.width;
} else if (carouselRect.right < 0) {
carousel.scrollLeft -= carouselRect.width;
}
});
// Duplicate the carouselContent on both the front and the back of the carousel to create the illusion.
for (let i = 0; i < carouselDuplicates; i++) {
const carouselDuplicate = carouselContent.cloneNode(true);
// For accessibility reasons...
carouselDuplicate.ariaHidden = true;
carouselDuplicate.querySelectorAll("a").forEach((element) => {
element.tabIndex = "-1";
});
carousel.prepend(carouselDuplicate);
carousel.append(carouselDuplicate.cloneNode(true));
}
// Shift the starting scroll position to the right.
carousel.scrollLeft += carouselContent.offsetWidth * carouselDuplicates;
// Start the update loop.
requestAnimationFrame(updateScroll);
});FAQs:
Q: How can I adjust the kinetic scrolling friction?
A: In the updateScroll function, locate the line scrollDelta = lerp(scrollDelta, 0, 0.045);. The 0.045 value controls how quickly the scrolling slows down. A larger value (e.g., 0.1) will make it stop much faster, while a smaller value will make it coast for longer.
Q: Can I make the carousel scroll automatically?
A: Yes. You would need to modify the updateScroll function. Instead of letting scrollDelta decay to zero, you could set it to a small, constant value if there’s no user interaction. For example: if (!carouselHasMouse && carouselTouches <= 0) { scrollDelta = 0.5; }. This would make it move continuously.
Q: What if I have a different number of cards?
A: The script is fully dynamic. You can add or remove as many <div class="card"> elements as you want in the HTML. The JavaScript will automatically measure the content width and adjust its calculations.
Q: What happens if I have fewer cards than the viewport width?
A: The duplication logic ensures smooth infinite scrolling regardless of card count. With fewer cards, you’ll notice more frequent repetition, but the scrolling behavior remains consistent.






