Skip to content

Instantly share code, notes, and snippets.

@KishorJena
Last active April 3, 2025 06:20
Show Gist options
  • Save KishorJena/02ba527672d26435998ac226a04fa712 to your computer and use it in GitHub Desktop.
Save KishorJena/02ba527672d26435998ac226a04fa712 to your computer and use it in GitHub Desktop.
Responsive carousel using MUI (material-ui) and React

A decent Carousel for MUI / React / Next

Features:

  • Responsive
  • Respects parent width
  • Swipe to scroll
  • Scroll to scroll
  • Drag to scroll
  • Click left/right buttons to scroll
  • MUI components
  • Can be used as vanilla React by replacing some components

While attempting to implement a simple carousel in MUI, I discovered it was not as straightforward as I anticipated. Placing items in a Stack, Grid, List, or Box with flex did not dynamically adjust the items to limit their width and respect the parent/container width. I This snippet can be used in vanilla react, NextJs or similar react base libraries/frameworks with small changes.

I have two varients, You can copy paste and check

import { Box, Icon, IconButton } from '@mui/material';
import { useEffect, useRef, useState } from 'react';
const items = [1, 2, 3, 4, 5, 6, 7];
const ResponsiveCarousel = () => {
const [visibleItems, setVisibleItems] = useState(4);
const [scrollIndex, setScrollIndex] = useState(0);
const containerRef = useRef(null);
const contentRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
useEffect(() => {
const handleResize = () => {
const containerWidth = containerRef.current?.offsetWidth;
const newVisibleItems = Math.max(1, Math.floor(containerWidth / 220));
setVisibleItems(newVisibleItems);
setScrollIndex(0);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const maxScrollIndex = Math.max(0, items.length - visibleItems);
const handleScroll = (direction) => {
setScrollIndex((prevIndex) => {
const newIndex = prevIndex + direction;
return Math.max(0, Math.min(newIndex, maxScrollIndex));
});
};
// Other event handlers...
useEffect(() => {
const content = contentRef.current;
if (content) {
// Add event listeners...
}
}, [isDragging, startX, scrollLeft, visibleItems, maxScrollIndex]);
const showNavigation = items.length > visibleItems;
const isContentWidthLessThanContainer = () => {
const contentWidth = contentRef.current?.scrollWidth;
const containerWidth = containerRef.current?.offsetWidth;
return contentWidth <= containerWidth;
};
return (
<Box
ref={containerRef}
sx={{
display: 'flex',
alignItems: 'center',
position: 'relative',
width: '100%',
overflow: 'hidden',
}}
>
{showNavigation && (
<IconButton
onClick={() => handleScroll(-1)}
disabled={scrollIndex === 0}
sx={{ position: 'absolute', left: 50, zIndex: 1 }}
>
<Icon>arrow_back_ios</Icon>
</IconButton>
)}
<Box
sx={{
display: 'flex',
overflow: 'hidden',
px: 4,
cursor: isDragging ? 'grabbing' : 'grab',
}}
>
<Box
ref={contentRef}
sx={{
display: 'flex',
justifyContent: 'center',
transition: isDragging ? 'none' : 'transform 0.3s ease-in-out',
transform: `translateX(-${scrollIndex * (100 / visibleItems)}%)`,
}}
>
{items.map((item, index) => (
<Box
key={index}
sx={{
flex: `0 0 ${100 / visibleItems}%`,
maxWidth: `${100 / visibleItems}%`,
padding: '10px',
boxSizing: 'border-box',
}}
>
<Box
sx={{
width: '100%',
paddingTop: '100%',
position: 'relative',
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '2rem',
}}
>
{item}
</Box>
</Box>
</Box>
))}
</Box>
</Box>
{showNavigation && (
<IconButton
onClick={() => handleScroll(1)}
disabled={scrollIndex >= maxScrollIndex || isContentWidthLessThanContainer()}
sx={{ position: 'absolute', right: 0, zIndex: 1 }}
>
<Icon>arrow_forward_ios</Icon>
</IconButton>
)}
</Box>
);
};
export default ResponsiveCarousel;
import React, { useState, useEffect, useRef } from 'react';
import { Box, IconButton } from '@mui/material';
import { ArrowCircleRightOutlined as LeftIcon, ArrowCircleLeftOutlined as RightIcon } from '@mui/icons-material';
const items = [1, 2, 3, 4, 5, 6, 7, 8];
const ResponsiveCarousel = () => {
const [scrollIndex, setScrollIndex] = useState(0);
const containerRef = useRef(null);
const contentRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const [showNavigation, setShowNavigation] = useState(false);
const checkOverflow = () => {
if (containerRef.current && contentRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const contentWidth = contentRef.current.scrollWidth;
setShowNavigation(contentWidth > containerWidth);
}
};
useEffect(() => {
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, []);
const handleScroll = (direction) => {
if (contentRef.current) {
const scrollAmount = direction * containerRef.current.offsetWidth;
contentRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
};
const handleMouseDown = (event) => {
setIsDragging(true);
setStartX(event.pageX - contentRef.current.offsetLeft);
setScrollLeft(contentRef.current.scrollLeft);
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleMouseMove = (event) => {
if (!isDragging) return;
event.preventDefault();
const x = event.pageX - contentRef.current.offsetLeft;
const walk = (x - startX) * 2;
contentRef.current.scrollLeft = scrollLeft - walk;
};
return (
<Box
ref={containerRef}
sx={{
display: 'flex',
alignItems: 'center',
position: 'relative',
width: '100%',
overflow: 'hidden',
}}
>
{showNavigation && (
<IconButton
onClick={() => handleScroll(-1)}
sx={{ position: 'absolute', left: 0, zIndex: 1 }}
>
<LeftIcon />
</IconButton>
)}
<Box
ref={contentRef}
sx={{
display: 'flex',
width: '100%',
overflowX: 'auto',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
'&::-webkit-scrollbar': { display: 'none' },
cursor: isDragging ? 'grabbing' : 'grab',
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onMouseMove={handleMouseMove}
>
{items.map((item, index) => (
<Box
key={index}
sx={{
flex: '0 0 auto',
width: '200px',
height: '200px',
margin: '0 10px',
backgroundColor: 'red',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '2rem',
}}
>
{item}
</Box>
))}
</Box>
{showNavigation && (
<IconButton
onClick={() => handleScroll(1)}
sx={{ position: 'absolute', right: 0, zIndex: 1 }}
>
<RightIcon />
</IconButton>
)}
</Box>
);
};
export default ResponsiveCarousel;
// No navigation button or gestures
import React, { useRef, useEffect } from 'react';
import { Box, useTheme, useMediaQuery, Grid2 } from '@mui/material';
const ResponsiveCarousel = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const isTablet = useMediaQuery(theme.breakpoints.between('sm', 'md'));
const carouselRef = useRef(null);
// Dynamic item width based on screen size
const getItemWidth = () => {
if (isMobile) return '100%'; // Only 1 item visible on mobile
if (isTablet) return '33.33%'; // 3 items visible on tablets
return '12.5%'; // 8 items visible on larger screens
};
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
// Custom hook to add non-passive event listener for scrolling
useEffect(() => {
const carousel = carouselRef.current;
const handleWheel = (e) => {
if (e.shiftKey) {
e.preventDefault();
carousel.scrollLeft += e.deltaY;
}
};
carousel.addEventListener('wheel', handleWheel, { passive: false });
return () => {
carousel.removeEventListener('wheel', handleWheel);
};
}, []);
return (
<Box
ref={carouselRef}
sx={{
display: 'flex',
overflowX: 'auto',
overflowY: 'hidden',
whiteSpace: 'nowrap',
width: '100%',
'&::-webkit-scrollbar': {
display: 'none',
},
msOverflowStyle: 'none',
scrollbarWidth: 'none',
}}
>
{numbers.map((number) => (
<Grid2
key={number}
item
sx={{
minWidth: getItemWidth(),
height: '100px',
backgroundColor: '#1976d2',
color: '#fff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '1.5rem',
marginRight: '8px',
borderRadius: '4px',
}}
>
{number}
</Grid2>
))}
</Box>
);
};
export default ResponsiveCarousel;
@Norlandz
Copy link

Norlandz commented Feb 9, 2025

I replaced {items.map with {React.Children.map(children, (child, index) => ( <React.Fragment key={index}>{child}</React.Fragment> ))} still works. Not sure if the key is good though.

ie:
modify ResponsiveCarouselCentered.jsx into

// Responsive carousel using MUI (material-ui) and React
// https://gist.github.com/KishorJena/02ba527672d26435998ac226a04fa712

import React, { useState, useEffect, useRef } from 'react';
import { Box, IconButton } from '@mui/material';
import { ArrowCircleRightOutlined as LeftIcon, ArrowCircleLeftOutlined as RightIcon } from '@mui/icons-material';

// const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];

export const ResponsiveCarousel: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [scrollIndex, setScrollIndex] = useState(0);
  const containerRef = useRef(null);
  const contentRef = useRef(null);
  const [isDragging, setIsDragging] = useState(false);
  const [startX, setStartX] = useState(0);
  const [scrollLeft, setScrollLeft] = useState(0);
  const [showNavigation, setShowNavigation] = useState(false);

  const checkOverflow = () => {
    if (containerRef.current && contentRef.current) {
      const containerWidth = containerRef.current.offsetWidth;
      const contentWidth = contentRef.current.scrollWidth;
      setShowNavigation(contentWidth > containerWidth);
    }
  };

  useEffect(() => {
    checkOverflow();
    const resizeObserver = new ResizeObserver(checkOverflow);
    if (containerRef.current) {
      resizeObserver.observe(containerRef.current);
    }
    return () => resizeObserver.disconnect();
  }, []);

  const handleScroll = (direction) => {
    if (contentRef.current) {
      const scrollAmount = direction * containerRef.current.offsetWidth;
      contentRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
    }
  };

  const handleMouseDown = (event) => {
    setIsDragging(true);
    setStartX(event.pageX - contentRef.current.offsetLeft);
    setScrollLeft(contentRef.current.scrollLeft);
  };

  const handleMouseUp = () => {
    setIsDragging(false);
  };

  const handleMouseMove = (event) => {
    if (!isDragging) return;
    event.preventDefault();
    const x = event.pageX - contentRef.current.offsetLeft;
    const walk = (x - startX) * 2;
    contentRef.current.scrollLeft = scrollLeft - walk;
  };

  return (
    <Box
      ref={containerRef}
      sx={{
        display: 'flex',
        alignItems: 'center',
        position: 'relative',
        width: '100%',
        overflow: 'hidden',
      }}
    >
      {showNavigation && (
        <IconButton onClick={() => handleScroll(-1)} sx={{ position: 'absolute', left: 0, zIndex: 1 }}>
          <RightIcon />
        </IconButton>
      )}
      <Box
        ref={contentRef}
        sx={{
          display: 'flex',
          width: '100%',
          overflowX: 'auto',
          scrollbarWidth: 'none',
          msOverflowStyle: 'none',
          '&::-webkit-scrollbar': { display: 'none' },
          cursor: isDragging ? 'grabbing' : 'grab',
        }}
        onMouseDown={handleMouseDown}
        onMouseUp={handleMouseUp}
        onMouseLeave={handleMouseUp}
        onMouseMove={handleMouseMove}
      >
        {/* {items.map((item, index) => (
          <Box
            key={index}
            sx={{
              flex: '0 0 auto',
              width: '200px',
              height: '200px',
              margin: '0 10px',
              backgroundColor: 'red',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              color: 'white',
              fontSize: '2rem',
            }}
          >
            {item}
          </Box>
        ))} */}
        {/* //? is the key ok? */}
        {React.Children.map(children, (child, index) => (
          <React.Fragment key={index}>{child}</React.Fragment>
        ))}
      </Box>
      {showNavigation && (
        <IconButton onClick={() => handleScroll(1)} sx={{ position: 'absolute', right: 0, zIndex: 1 }}>
          <LeftIcon />
        </IconButton>
      )}
    </Box>
  );
};

and use it like

        <ResponsiveCarousel>
          {Array.from(cardList).map((item, index) => {
            return <CardPanel key={item.title} title={item.title} description={item.description} image={item.image} />;
          })}
        </ResponsiveCarousel>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment