From a75794e0995276ad12904f44585e793ce5aef66f Mon Sep 17 00:00:00 2001 From: Juan Carlos Diaz Date: Sat, 11 Apr 2026 22:37:51 +0200 Subject: [PATCH 1/5] fix(carousel): implement circular transition between last and first slides Replace linear scroll-through-all-slides behavior with a clone-based infinite loop technique. Prepend a clone of the last slide and append a clone of the first slide, then instantly reposition after the smooth transition completes. This gives a seamless circular carousel experience matching vanilla Flowbite behavior. Also adds an isAnimating guard to prevent rapid clicks from breaking the transition state. Fixes #1103 Co-Authored-By: Claude Opus 4.6 --- .../ui/src/components/Carousel/Carousel.tsx | 129 ++++++++++++++++-- 1 file changed, 115 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/Carousel/Carousel.tsx b/packages/ui/src/components/Carousel/Carousel.tsx index a2710d5d8..e4bc63c4e 100644 --- a/packages/ui/src/components/Carousel/Carousel.tsx +++ b/packages/ui/src/components/Carousel/Carousel.tsx @@ -89,6 +89,7 @@ export const Carousel = forwardRef((props, ref) = const [activeItem, setActiveItem] = useState(0); const [isDragging, setIsDragging] = useState(false); const [isHovering, setIsHovering] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); const didMountRef = useRef(false); @@ -102,31 +103,96 @@ export const Carousel = forwardRef((props, ref) = [children, theme.item.base], ); + const itemCount = items?.length ?? 0; + const navigateTo = useCallback( (item: number) => () => { - if (!items) return; - item = (item + items.length) % items.length; - if (carouselContainer.current) { - carouselContainer.current.scrollLeft = carouselContainer.current.clientWidth * item; + if (!items || isAnimating) return; + + const container = carouselContainer.current; + if (!container) return; + + const totalItems = items.length; + const targetItem = ((item % totalItems) + totalItems) % totalItems; + + const isWrappingForward = activeItem === totalItems - 1 && item >= totalItems; + const isWrappingBackward = activeItem === 0 && item < 0; + + if (isWrappingForward || isWrappingBackward) { + setIsAnimating(true); + + // Scroll to the clone (last element for backward, first-after-last for forward) + if (isWrappingForward) { + // Clone of first slide is at the end (index = totalItems + 1 in the extended list) + // But we use scrollLeft directly + container.scrollTo({ + left: container.clientWidth * (totalItems + 1), + behavior: "smooth", + }); + } else { + // Clone of last slide is at position 0 + container.scrollTo({ + left: 0, + behavior: "smooth", + }); + } + + // After the smooth scroll animation, jump instantly to the real slide + const onTransitionDone = () => { + container.style.scrollBehavior = "auto"; + if (isWrappingForward) { + container.scrollLeft = container.clientWidth * 1; // real first slide at index 1 + } else { + container.scrollLeft = container.clientWidth * totalItems; // real last slide + } + container.style.scrollBehavior = ""; + setIsAnimating(false); + }; + + setTimeout(onTransitionDone, 500); + setActiveItem(targetItem); + } else { + // Normal navigation - account for the prepended clone + container.scrollTo({ + left: container.clientWidth * (targetItem + 1), + behavior: "smooth", + }); + setActiveItem(targetItem); } - setActiveItem(item); }, - [items], + [items, activeItem, isAnimating], ); + // Initialize scroll position to first real slide (past the prepended clone) useEffect(() => { - if (carouselContainer.current && !isDragging && carouselContainer.current.scrollLeft !== 0) { - setActiveItem(Math.round(carouselContainer.current.scrollLeft / carouselContainer.current.clientWidth)); + const container = carouselContainer.current; + if (container && items && items.length > 0) { + container.style.scrollBehavior = "auto"; + container.scrollLeft = container.clientWidth * 1; + container.style.scrollBehavior = ""; } - }, [isDragging]); + }, [items]); + + useEffect(() => { + if (carouselContainer.current && !isDragging && !isAnimating) { + const container = carouselContainer.current; + const rawIndex = Math.round(container.scrollLeft / container.clientWidth); + // Account for the prepended clone: real items start at index 1 + const totalItems = items?.length ?? 0; + if (totalItems > 0) { + const realIndex = ((rawIndex - 1) % totalItems + totalItems) % totalItems; + setActiveItem(realIndex); + } + } + }, [isDragging, isAnimating, items]); useEffect(() => { if (slide && !(pauseOnHover && isHovering)) { - const intervalId = setInterval(() => !isDragging && navigateTo(activeItem + 1)(), slideInterval ?? 3000); + const intervalId = setInterval(() => !isDragging && !isAnimating && navigateTo(activeItem + 1)(), slideInterval ?? 3000); return () => clearInterval(intervalId); } - }, [activeItem, isDragging, navigateTo, slide, slideInterval, pauseOnHover, isHovering]); + }, [activeItem, isDragging, isAnimating, navigateTo, slide, slideInterval, pauseOnHover, isHovering]); useEffect(() => { if (didMountRef.current) { @@ -138,9 +204,44 @@ export const Carousel = forwardRef((props, ref) = const handleDragging = (dragging: boolean) => () => setIsDragging(dragging); + // Handle drag end: snap to nearest real slide and fix wrap-around + const handleDragEnd = useCallback(() => { + setIsDragging(false); + const container = carouselContainer.current; + if (!container || !items) return; + + const totalItems = items.length; + const rawIndex = Math.round(container.scrollLeft / container.clientWidth); + + // If scrolled to the prepended clone (index 0), jump to real last slide + if (rawIndex <= 0) { + container.style.scrollBehavior = "auto"; + container.scrollLeft = container.clientWidth * totalItems; + container.style.scrollBehavior = ""; + setActiveItem(totalItems - 1); + } + // If scrolled to the appended clone (index totalItems + 1), jump to real first slide + else if (rawIndex >= totalItems + 1) { + container.style.scrollBehavior = "auto"; + container.scrollLeft = container.clientWidth * 1; + container.style.scrollBehavior = ""; + setActiveItem(0); + } else { + setActiveItem(rawIndex - 1); + } + }, [items]); + const setHoveringTrue = useCallback(() => setIsHovering(true), []); const setHoveringFalse = useCallback(() => setIsHovering(false), []); + // Build the extended items array: [cloneLast, ...items, cloneFirst] + const extendedItems = useMemo(() => { + if (!items || items.length === 0) return items; + const lastClone = cloneElement(items[items.length - 1], { key: "clone-last" }); + const firstClone = cloneElement(items[0], { key: "clone-first" }); + return [lastClone, ...items, firstClone]; + }, [items]); + return (
((props, ref) = className={twMerge(theme.scrollContainer.base, (isDeviceMobile || !isDragging) && theme.scrollContainer.snap)} draggingClassName="cursor-grab" innerRef={carouselContainer} - onEndScroll={handleDragging(false)} + onEndScroll={handleDragEnd} onStartScroll={handleDragging(draggable)} vertical={false} horizontal={draggable} > - {items?.map((item, index) => ( + {extendedItems?.map((item, index) => (
{item} From 1739c595a77a5599aaff13f8bed9a0658e745e51 Mon Sep 17 00:00:00 2001 From: seojcarlos Date: Sun, 12 Apr 2026 09:59:09 +0200 Subject: [PATCH 2/5] fix(carousel): address code review feedback - Extract hardcoded 500ms to SCROLL_ANIMATION_DURATION_MS constant - Add transitionTimeoutRef to prevent memory leak on unmount or rapid navigation (clear pending timeout before setting a new one) - Add cleanup effect to clear timeout on component unmount - Guard initialization effect with initializedRef to prevent scroll reset on dynamic children changes --- .../ui/src/components/Carousel/Carousel.tsx | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/Carousel/Carousel.tsx b/packages/ui/src/components/Carousel/Carousel.tsx index e4bc63c4e..19d0a3f56 100644 --- a/packages/ui/src/components/Carousel/Carousel.tsx +++ b/packages/ui/src/components/Carousel/Carousel.tsx @@ -62,6 +62,8 @@ export interface CarouselProps extends ComponentProps<"div">, ThemingProps, ThemingProps {} +const SCROLL_ANIMATION_DURATION_MS = 500; + export const Carousel = forwardRef((props, ref) => { const provider = useThemeProvider(); const theme = useResolveTheme( @@ -92,6 +94,8 @@ export const Carousel = forwardRef((props, ref) = const [isAnimating, setIsAnimating] = useState(false); const didMountRef = useRef(false); + const initializedRef = useRef(false); + const transitionTimeoutRef = useRef | null>(null); const items = useMemo( () => @@ -121,16 +125,17 @@ export const Carousel = forwardRef((props, ref) = if (isWrappingForward || isWrappingBackward) { setIsAnimating(true); + if (transitionTimeoutRef.current) { + clearTimeout(transitionTimeoutRef.current); + } + // Scroll to the clone (last element for backward, first-after-last for forward) if (isWrappingForward) { - // Clone of first slide is at the end (index = totalItems + 1 in the extended list) - // But we use scrollLeft directly container.scrollTo({ left: container.clientWidth * (totalItems + 1), behavior: "smooth", }); } else { - // Clone of last slide is at position 0 container.scrollTo({ left: 0, behavior: "smooth", @@ -141,15 +146,16 @@ export const Carousel = forwardRef((props, ref) = const onTransitionDone = () => { container.style.scrollBehavior = "auto"; if (isWrappingForward) { - container.scrollLeft = container.clientWidth * 1; // real first slide at index 1 + container.scrollLeft = container.clientWidth * 1; } else { - container.scrollLeft = container.clientWidth * totalItems; // real last slide + container.scrollLeft = container.clientWidth * totalItems; } container.style.scrollBehavior = ""; setIsAnimating(false); + transitionTimeoutRef.current = null; }; - setTimeout(onTransitionDone, 500); + transitionTimeoutRef.current = setTimeout(onTransitionDone, SCROLL_ANIMATION_DURATION_MS); setActiveItem(targetItem); } else { // Normal navigation - account for the prepended clone @@ -166,7 +172,8 @@ export const Carousel = forwardRef((props, ref) = // Initialize scroll position to first real slide (past the prepended clone) useEffect(() => { const container = carouselContainer.current; - if (container && items && items.length > 0) { + if (container && items && items.length > 0 && !initializedRef.current) { + initializedRef.current = true; container.style.scrollBehavior = "auto"; container.scrollLeft = container.clientWidth * 1; container.style.scrollBehavior = ""; @@ -180,7 +187,7 @@ export const Carousel = forwardRef((props, ref) = // Account for the prepended clone: real items start at index 1 const totalItems = items?.length ?? 0; if (totalItems > 0) { - const realIndex = ((rawIndex - 1) % totalItems + totalItems) % totalItems; + const realIndex = (((rawIndex - 1) % totalItems) + totalItems) % totalItems; setActiveItem(realIndex); } } @@ -188,7 +195,10 @@ export const Carousel = forwardRef((props, ref) = useEffect(() => { if (slide && !(pauseOnHover && isHovering)) { - const intervalId = setInterval(() => !isDragging && !isAnimating && navigateTo(activeItem + 1)(), slideInterval ?? 3000); + const intervalId = setInterval( + () => !isDragging && !isAnimating && navigateTo(activeItem + 1)(), + slideInterval ?? 3000, + ); return () => clearInterval(intervalId); } @@ -202,6 +212,15 @@ export const Carousel = forwardRef((props, ref) = } }, [onSlideChange, activeItem]); + // Cleanup transition timeout on unmount + useEffect(() => { + return () => { + if (transitionTimeoutRef.current) { + clearTimeout(transitionTimeoutRef.current); + } + }; + }, []); + const handleDragging = (dragging: boolean) => () => setIsDragging(dragging); // Handle drag end: snap to nearest real slide and fix wrap-around @@ -266,7 +285,7 @@ export const Carousel = forwardRef((props, ref) =
{item} From 3c071e01993b1db506d45c21ae876d5779fb559c Mon Sep 17 00:00:00 2001 From: seojcarlos Date: Sun, 12 Apr 2026 09:59:20 +0200 Subject: [PATCH 3/5] chore: add changeset for carousel circular transition --- .changeset/carousel-circular-transition.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/carousel-circular-transition.md diff --git a/.changeset/carousel-circular-transition.md b/.changeset/carousel-circular-transition.md new file mode 100644 index 000000000..fbc7eb733 --- /dev/null +++ b/.changeset/carousel-circular-transition.md @@ -0,0 +1,12 @@ +--- +"flowbite-react": patch +--- + +fix(carousel): seamless circular transition between last and first slides + +### Changes + +- [x] implement circular carousel using clone-based technique for smooth wrap-around +- [x] extract animation duration to named constant +- [x] prevent memory leak with timeout cleanup on unmount +- [x] guard initialization to prevent reset on dynamic children updates From 666bcf283c1b657a65a99eaab0819bf12de46f70 Mon Sep 17 00:00:00 2001 From: seojcarlos Date: Sun, 12 Apr 2026 10:09:23 +0200 Subject: [PATCH 4/5] test(carousel): update tests for circular transition with cloned slides - Mock Element.prototype.scrollTo for jsdom compatibility - Use realCarouselItems() helper to skip clone wrappers (extended items: [cloneLast, ...items, cloneFirst]) --- .../src/components/Carousel/Carousel.test.tsx | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/components/Carousel/Carousel.test.tsx b/packages/ui/src/components/Carousel/Carousel.test.tsx index 0cb726989..042a497e7 100644 --- a/packages/ui/src/components/Carousel/Carousel.test.tsx +++ b/packages/ui/src/components/Carousel/Carousel.test.tsx @@ -7,6 +7,8 @@ import { Carousel } from "./Carousel"; beforeEach(() => { vi.useFakeTimers(); vi.spyOn(global, "setTimeout"); + // Mock scrollTo since jsdom does not implement it + Element.prototype.scrollTo = vi.fn(); }); afterEach(() => { @@ -19,8 +21,9 @@ describe("Components / Carousel", () => { it("should render and show first item", () => { render(); - expect(carouselItems()[0]).toHaveAttribute("data-active", "true"); - expect(carouselItems()[1]).toHaveAttribute("data-active", "false"); + // With cloned slides the real items start at index 1 + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[1]).toHaveAttribute("data-active", "false"); expect(carouselIndicators()).toHaveLength(5); expect(carouselIndicators()[0]).toHaveClass(activeIndicatorClasses); expect(carouselIndicators()[1]).toHaveClass(nonActiveIndicatorClasses); @@ -38,14 +41,14 @@ describe("Components / Carousel", () => { render(); expect(carouselIndicators()[0]).toHaveClass(activeIndicatorClasses); - expect(carouselItems()[0]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "true"); act(() => { fireEvent.click(carouselIndicators()[3]); }); - expect(carouselItems()[0]).toHaveAttribute("data-active", "false"); - expect(carouselItems()[3]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "false"); + expect(realCarouselItems()[3]).toHaveAttribute("data-active", "true"); expect(carouselIndicators()[0]).not.toHaveClass(activeIndicatorClasses); expect(carouselIndicators()[3]).toHaveClass(activeIndicatorClasses); }); @@ -61,14 +64,14 @@ describe("Components / Carousel", () => { render(); expect(carouselIndicators()[0]).toHaveClass(activeIndicatorClasses); - expect(carouselItems()[0]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "true"); act(() => { fireEvent.click(carouselRightControl()); }); - expect(carouselItems()[0]).toHaveAttribute("data-active", "false"); - expect(carouselItems()[1]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "false"); + expect(realCarouselItems()[1]).toHaveAttribute("data-active", "true"); expect(carouselIndicators()[0]).not.toHaveClass(activeIndicatorClasses); expect(carouselIndicators()[1]).toHaveClass(activeIndicatorClasses); }); @@ -76,8 +79,8 @@ describe("Components / Carousel", () => { it("should transition to the next item after about 3 s by default", () => { render(); - expect(carouselItems()[0]).toHaveAttribute("data-active", "true"); - expect(carouselItems()[1]).toHaveAttribute("data-active", "false"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[1]).toHaveAttribute("data-active", "false"); expect(carouselIndicators()[0]).toHaveClass(activeIndicatorClasses); expect(carouselIndicators()[1]).toHaveClass(nonActiveIndicatorClasses); @@ -85,8 +88,8 @@ describe("Components / Carousel", () => { vi.advanceTimersByTime(3000); }); - expect(carouselItems()[0]).toHaveAttribute("data-active", "false"); - expect(carouselItems()[1]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "false"); + expect(realCarouselItems()[1]).toHaveAttribute("data-active", "true"); expect(carouselIndicators()[0]).toHaveClass(nonActiveIndicatorClasses); expect(carouselIndicators()[1]).toHaveClass(activeIndicatorClasses); }); @@ -94,8 +97,8 @@ describe("Components / Carousel", () => { it("should transition to the next item after `slideInterval` when it is provided", () => { render(); - expect(carouselItems()[0]).toHaveAttribute("data-active", "true"); - expect(carouselItems()[1]).toHaveAttribute("data-active", "false"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[1]).toHaveAttribute("data-active", "false"); expect(carouselIndicators()[0]).toHaveClass(activeIndicatorClasses); expect(carouselIndicators()[1]).toHaveClass(nonActiveIndicatorClasses); @@ -103,8 +106,8 @@ describe("Components / Carousel", () => { vi.advanceTimersByTime(9000); }); - expect(carouselItems()[0]).toHaveAttribute("data-active", "false"); - expect(carouselItems()[1]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "false"); + expect(realCarouselItems()[1]).toHaveAttribute("data-active", "true"); expect(carouselIndicators()[0]).toHaveClass(nonActiveIndicatorClasses); expect(carouselIndicators()[1]).toHaveClass(activeIndicatorClasses); }); @@ -112,8 +115,8 @@ describe("Components / Carousel", () => { it("should not automatically transition to the next item when `slide={false}`", () => { render(); - expect(carouselItems()[0]).toHaveAttribute("data-active", "true"); - expect(carouselItems()[1]).toHaveAttribute("data-active", "false"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[1]).toHaveAttribute("data-active", "false"); expect(carouselIndicators()[0]).toHaveClass(activeIndicatorClasses); expect(carouselIndicators()[1]).toHaveClass(nonActiveIndicatorClasses); @@ -121,8 +124,8 @@ describe("Components / Carousel", () => { vi.advanceTimersByTime(3000); }); - expect(carouselItems()[0]).toHaveAttribute("data-active", "true"); - expect(carouselItems()[1]).toHaveAttribute("data-active", "false"); + expect(realCarouselItems()[0]).toHaveAttribute("data-active", "true"); + expect(realCarouselItems()[1]).toHaveAttribute("data-active", "false"); expect(carouselIndicators()[0]).toHaveClass(activeIndicatorClasses); expect(carouselIndicators()[1]).toHaveClass(nonActiveIndicatorClasses); }); @@ -142,6 +145,11 @@ const TestCarousel = (props: CarouselProps) => ( ); const carouselItems = () => screen.getAllByTestId("carousel-item"); +// Real items exclude the prepended and appended clones +const realCarouselItems = () => { + const items = carouselItems(); + return items.slice(1, items.length - 1); +}; const carouselIndicators = () => screen.getAllByTestId("carousel-indicator"); const carouselLeftControl = () => screen.getByTestId("carousel-left-control"); const carouselRightControl = () => screen.getByTestId("carousel-right-control"); From bda2185ff0aae78da957c11486aa7e85ffe35fa7 Mon Sep 17 00:00:00 2001 From: seojcarlos Date: Sun, 12 Apr 2026 10:18:32 +0200 Subject: [PATCH 5/5] fix(carousel): mock clientWidth in tests for jsdom compatibility jsdom does not implement clientWidth, causing scroll-based index calculations to produce incorrect results. Adding a mock ensures the carousel correctly identifies the active slide in tests. --- packages/ui/src/components/Carousel/Carousel.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/src/components/Carousel/Carousel.test.tsx b/packages/ui/src/components/Carousel/Carousel.test.tsx index 042a497e7..b5450982e 100644 --- a/packages/ui/src/components/Carousel/Carousel.test.tsx +++ b/packages/ui/src/components/Carousel/Carousel.test.tsx @@ -9,6 +9,8 @@ beforeEach(() => { vi.spyOn(global, "setTimeout"); // Mock scrollTo since jsdom does not implement it Element.prototype.scrollTo = vi.fn(); + // Mock clientWidth so scroll-based index calculations work correctly in jsdom + Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: 100 }); }); afterEach(() => {