Last active
August 29, 2025 17:22
-
-
Save fiuzagr/381d5d3ce3dec31c2d3c77162e9178c9 to your computer and use it in GitHub Desktop.
Simple Responsive Carousel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client"; | |
| import { | |
| Children, | |
| cloneElement, | |
| ComponentPropsWithRef, | |
| isValidElement, | |
| MouseEvent as ReactMouseEvent, | |
| ReactElement, | |
| ReactNode, | |
| TouchEvent as ReactTouchEvent, | |
| useLayoutEffect, | |
| useRef, | |
| useState, | |
| } from "react"; | |
| import { twMerge } from "tailwind-merge"; | |
| import { debounce } from "lodash/debounce"; | |
| interface Props { | |
| children: ReactNode; | |
| className?: string; | |
| buttonsPlacement?: "inside" | "outside"; | |
| } | |
| export const SimpleCarousel = ({ | |
| children, | |
| className, | |
| buttonsPlacement, | |
| ...props | |
| }: Props) => { | |
| const childrenCount = Children.count(children); | |
| const [childWidth, setChildWidth] = useState(0); | |
| const [touchStartPosition, setTouchStartPosition] = useState({ x: 0 }); | |
| const [lastDistance, setLastDistance] = useState(0); | |
| const [carouselEnabled, setCarouselEnabled] = useState(false); | |
| const [childDisplayed, setChildDisplayed] = useState(0); | |
| const [visualGap, setVisualGap] = useState(0); | |
| const [numberOfMoves, setNumberOfMoves] = useState(0); | |
| const [lastSign, setLastSign] = useState(0); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const carouselRef = useRef<HTMLDivElement>(null); | |
| const firstChildRef = useRef<HTMLDivElement>(null); | |
| const translateX = useRef( | |
| debounce((distance: number) => { | |
| if (containerRef.current) { | |
| containerRef.current.style.transform = `translateX(${distance}px)`; | |
| } | |
| }, 6), | |
| ); | |
| const moveCarousel = (sign: number) => { | |
| const inverseSign = sign * -1; | |
| const nextChildDisplayed = childDisplayed + inverseSign; | |
| const movedDistance = childWidth * sign; | |
| let distance = movedDistance + lastDistance; | |
| if (nextChildDisplayed === 0) { | |
| distance = 0; | |
| sign = 0; | |
| } else if (nextChildDisplayed === numberOfMoves) { | |
| distance = childWidth * numberOfMoves * sign + visualGap; | |
| sign = 0; | |
| } else if (lastSign !== 0 && sign !== lastSign) { | |
| distance += visualGap * sign; | |
| } | |
| setChildDisplayed(nextChildDisplayed); | |
| setLastSign(sign); | |
| translateX.current(distance); | |
| setLastDistance(distance); | |
| }; | |
| const onMoveCarouselStart = carouselEnabled | |
| ? <T extends ReactMouseEvent | ReactTouchEvent>(e: T) => { | |
| const isTouch = e instanceof TouchEvent; | |
| const objX = isTouch ? e.changedTouches[0] : e; | |
| const positionX = (objX as ReactMouseEvent)?.clientX || 0; | |
| setTouchStartPosition({ x: positionX }); | |
| } | |
| : undefined; | |
| const onMoveCarouselMove = carouselEnabled | |
| ? <T extends ReactMouseEvent | ReactTouchEvent>(e: T) => { | |
| const isTouch = e instanceof TouchEvent; | |
| const objX = isTouch ? e.changedTouches[0] : e; | |
| const positionX = (objX as ReactMouseEvent)?.clientX || 0; | |
| const movedDistance = positionX - touchStartPosition.x; | |
| const distance = movedDistance + lastDistance; | |
| translateX.current(distance); | |
| } | |
| : undefined; | |
| const onMoveCarouselEnd = carouselEnabled | |
| ? <T extends ReactMouseEvent | ReactTouchEvent>(e: T) => { | |
| const isTouch = e instanceof TouchEvent; | |
| const objX = isTouch ? e.changedTouches[0] : e; | |
| const positionX = (objX as ReactMouseEvent)?.clientX || 0; | |
| const halfChildWidth = childWidth / 2; | |
| const movedDistance = positionX - touchStartPosition.x; | |
| const sign = Math.sign(movedDistance); | |
| const movingForward = sign < 0; | |
| const movingBackward = sign > 0; | |
| const allowedToMoveForward = | |
| movingForward && childDisplayed < numberOfMoves; | |
| const allowedToMoveBackward = movingBackward && childDisplayed > 0; | |
| const allowedMovement = | |
| Math.abs(movedDistance) > halfChildWidth && | |
| (allowedToMoveForward || allowedToMoveBackward); | |
| if (allowedMovement) { | |
| moveCarousel(sign); | |
| } | |
| } | |
| : undefined; | |
| useLayoutEffect(() => { | |
| const defineLayoutSizes = () => { | |
| const childOffsetWidth = firstChildRef.current?.offsetWidth || 0; | |
| const containerOffsetWidth = containerRef.current?.offsetWidth || 0; | |
| const containerStyles = containerRef.current | |
| ? window.getComputedStyle(containerRef.current) | |
| : null; | |
| const gap = parseFloat(containerStyles?.gap || "16"); | |
| const childWidth = childOffsetWidth + gap; | |
| const childrenDisplayed = Math.trunc( | |
| containerOffsetWidth / childOffsetWidth, | |
| ); | |
| const visualGap = | |
| containerOffsetWidth - | |
| childOffsetWidth * childrenDisplayed - | |
| gap * (childrenDisplayed - 1); | |
| if (childrenDisplayed < childrenCount) { | |
| setCarouselEnabled(true); | |
| } | |
| setChildWidth(childWidth); | |
| setNumberOfMoves(childrenCount - childrenDisplayed); | |
| setVisualGap(visualGap); | |
| setChildDisplayed(0); | |
| setLastSign(0); | |
| setLastDistance(0); | |
| translateX.current(0); | |
| }; | |
| window.addEventListener("resize", defineLayoutSizes); | |
| defineLayoutSizes(); | |
| return () => { | |
| window.removeEventListener("resize", defineLayoutSizes); | |
| }; | |
| }, [childrenCount, firstChildRef]); | |
| return ( | |
| <div | |
| {...props} | |
| className={twMerge( | |
| "relative flex flex-row items-center gap-3 overflow-hidden", | |
| className, | |
| )} | |
| > | |
| <button | |
| className={twMerge( | |
| "absolute left-1 z-10", | |
| buttonsPlacement === "outside" ? "relative" : "", | |
| )} | |
| onClick={() => moveCarousel(1)} | |
| disabled={!carouselEnabled || childDisplayed <= 0} | |
| > | |
| {"<"} | |
| </button> | |
| <div | |
| ref={carouselRef} | |
| className={"overflow-hidden"} | |
| /* mobile events */ | |
| onTouchStart={onMoveCarouselStart} | |
| onTouchMove={onMoveCarouselMove} | |
| onTouchEnd={onMoveCarouselEnd} | |
| /* desktop events */ | |
| onMouseDown={onMoveCarouselStart} | |
| onMouseUp={onMoveCarouselEnd} | |
| > | |
| <div | |
| ref={containerRef} | |
| className={"grid gap-2 transition-transform duration-500 ease-out"} | |
| style={{ | |
| gridTemplateColumns: `repeat(${childrenCount}, 1fr)`, | |
| }} | |
| > | |
| {Children.toArray(children) | |
| .filter((child) => isValidElement(child)) | |
| .map((child, key) => { | |
| if (key === 0) { | |
| return cloneElement( | |
| child as ReactElement<ComponentPropsWithRef<"div">>, | |
| { | |
| ref: firstChildRef, | |
| }, | |
| ); | |
| } | |
| return child; | |
| })} | |
| </div> | |
| </div> | |
| <button | |
| className={twMerge( | |
| "absolute right-1 z-10", | |
| buttonsPlacement === "outside" ? "relative" : "", | |
| )} | |
| onClick={() => moveCarousel(-1)} | |
| disabled={!carouselEnabled || childDisplayed >= numberOfMoves} | |
| > | |
| {">"} | |
| </button> | |
| </div> | |
| ); | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment