ARTICLE AD BOX
I'm so frustrated and about to tear my hair out because React just doesn't seem to want to be consistent but I know there's something I'm missing as a developer.
I'm working on a back to front carousel of items. Cycling forward works perfectly, animations are nice and clean on everything. However, cycling backward (which generally follows that same steps as cycling forward) does not want to animate smoothly no matter what. Here is the Carousel code:
import useTimeout from "@/lib/hooks/useTimeout" import { ChevronDown, ChevronUp } from "lucide-react" import { CSSProperties, MouseEvent, useEffect, useRef, useState } from "react" export type CarouselItem = { key: string | number | bigint title: string element: React.ReactNode } type ForwardCarouselProps = { containerStyle?: CSSProperties content: [CarouselItem, CarouselItem, CarouselItem, ...CarouselItem[]] // minimum 3 items CarouselItem array maxDisplayItems?: number } export default function ForwardCarousel({ containerStyle, content, maxDisplayItems } : ForwardCarouselProps) { const displayCount: number = Math.max(maxDisplayItems || content.length, 3) const [currentItemIndex, setCurrentItemIndex] = useState<number>(0) const backwardTimer = useTimeout() const forwardTimer = useTimeout() function getPreviousItemIndex() { return currentItemIndex === 0 ? content.length - 1 : currentItemIndex - 1 } function getNextItemIndex() { return currentItemIndex === content.length - 1 ? 0 : currentItemIndex + 1 } function cycleBackward(event: MouseEvent<HTMLButtonElement>) { event.preventDefault() backwardTimer.begin(50) setCurrentItemIndex(getPreviousItemIndex()) } function cycleForward(event: MouseEvent<HTMLButtonElement>) { event.preventDefault() forwardTimer.begin(50) setCurrentItemIndex(getNextItemIndex()) } function oneWayCyclicalSlice(arr: Array<any>, start: number, end: number): Array<any> { if (start > end) throw new Error("{\nfunction 'cyclicalSlice' parameter error:\n 'start' may not be less than 'end'\n}") if (end > arr.length) { let remainingLength: number = end - start let result: Array<any> = arr.slice(start, arr.length) remainingLength -= (arr.length - start) while (remainingLength !== 0) { let slice: number = Math.min(remainingLength, arr.length) result = [...result, ...arr.slice(0, slice)] remainingLength -= slice } return result } return arr.slice(start, end) } useEffect(() => { if (backwardTimer.completed) backwardTimer.reset() if (forwardTimer.completed) forwardTimer.reset() }, [backwardTimer.completed, forwardTimer.completed]) return ( <article style={containerStyle}> <header className="w-full h-[10%] flex flex-row justify-center items-center"> <h2 className="text-dark-white text-2xl font-jbm">{content[currentItemIndex].title}</h2> </header> <section className="w-full h-[5%] flex flex-row justify-end items-center"> <button onClick={cycleBackward}> <ChevronUp /> </button> <button onClick={cycleForward}> <ChevronDown /> </button> </section> <ul className="relative w-full h-[85%] z-0 flex flex-col justify-center items-center"> { oneWayCyclicalSlice(content, currentItemIndex, currentItemIndex + displayCount).map((item, index) => { let xScale: number = 1 - ((0.2 / displayCount) * index) let yTranslate: number = 20 - ((20 / displayCount) * index) let activeOpacity: number = 1 - ((0.5 / displayCount) * index) let activeZ: number = displayCount - index let hasAnimations: boolean = true if (index === 0 && backwardTimer.started && !backwardTimer.completed) { yTranslate = 20 + 5 activeOpacity = 0 activeZ = -100 hasAnimations = false } if (index === displayCount - 1 && forwardTimer.started && !forwardTimer.completed) { yTranslate = 0 activeOpacity = 0 activeZ = 100 hasAnimations = false } return ( <li key={item.key} style={{opacity: activeOpacity, zIndex: activeZ, transform: `translateY(${yTranslate}%) scaleX(${xScale})`, transition: hasAnimations ? "opacity 0.2s linear, transform 0.25s linear" : undefined}} className="absolute top-0 w-full h-3/4 flex flex-col justify-center items-center"> { item.element } </li> ) }) } </ul> </article> ) }Carousel items keep snapping instead of animating smoothly (only for cycleBackward() function). Only the item in the carousel being moved to the front (at index 0) will actually animate. The rest of the Carousel items just snap to where they're supposed to be without animating. The frustrating part is if I fix one problem, another shows up. Again, cycleForward() works great and gives no issues.
Things I Tried
Interestingly, if I set maxDisplayItems to any value less than the size of the original array of Carousel items, everything works! This makes all items in the Carousel cycling either backwards or forwards animate correctly and it looks great (makes me think it's a problem with React not being able to tell which items to re-render properly in the problematic case). However, this should be working even if I have all items in the original array displayed in the stack of items.
Instead of using item.key for the list keys, if I instead replace key value with
index === 0 && backwardTimer.started && !backwardTimer.completed ? "entering" : item.key(a temp key to force re-render), then Carousel items will animate correctly except for the one item being moved from the back to the front, which in this case will start snapping (but other items when cycling backward will begin to animate properly).
I thought maybe my useTimeout hook was the culprit since it uses state but I've tried to remake this without useTimeout to no avail; same result, cycling forward works but cycling backward still gives the same issues.
Here is the parent component (I know code needs work but I'm trying to get something working properly before I focus on that):
"use client" import { PAGE_LOGIN, PAGE_SIGNUP } from "@/lib/constants/routes" import ForwardCarousel, { CarouselItem } from "../utils/ForwardCarousel" export default function Banner() { const carouselContent: [CarouselItem, CarouselItem, CarouselItem, ...CarouselItem[]] = [ { key: "initial", title: "Messaging Made Easy", element: ( <img src="https://res.cloudinary.com/dm9lygtbe/image/upload/v1747994561/bannerShot_qsqpxg.png" className="h-full aspect-square rounded-lg hue-rotate-15" alt="Banner Shot" width={500} height={500} /> ) }, { key: "mid", title: "Messaging Made Easy", element: ( <img src="https://res.cloudinary.com/dm9lygtbe/image/upload/v1747994561/bannerShot_qsqpxg.png" className="h-full aspect-square rounded-lg hue-rotate-30" alt="Banner Shot" width={500} height={500} /> ) }, { key: "final", title: "Messaging Made Easy", element: ( <img src="https://res.cloudinary.com/dm9lygtbe/image/upload/v1747994561/bannerShot_qsqpxg.png" className="h-full aspect-square rounded-lg hue-rotate-45 invert-50" alt="Banner Shot" width={500} height={500} /> ) }, { key: "then", title: "Messaging Made Easy", element: ( <img src="https://res.cloudinary.com/dm9lygtbe/image/upload/v1747994561/bannerShot_qsqpxg.png" className="h-full aspect-square rounded-lg hue-rotate-60" alt="Banner Shot" width={500} height={500} /> ) }, { key: "then2", title: "Messaging Made Easy", element: ( <img src="https://res.cloudinary.com/dm9lygtbe/image/upload/v1747994561/bannerShot_qsqpxg.png" className="h-full aspect-square rounded-lg invert-25" alt="Banner Shot" width={500} height={500} /> ) }, { key: "then3", title: "Messaging Made Easy", element: ( <img src="https://res.cloudinary.com/dm9lygtbe/image/upload/v1747994561/bannerShot_qsqpxg.png" className="h-full aspect-square rounded-lg hue-rotate-60 invert-75" alt="Banner Shot" width={500} height={500} /> ) } ] return ( <main className="w-screen h-[80vh] flex flex-row justify-center items-center"> <section className="w-full md:w-1/2 h-full flex flex-col items-center"> <ForwardCarousel containerStyle={{width: "100%", height: "90%"}} content={carouselContent} maxDisplayItems={carouselContent.length - 1} /> <aside className="w-3/4 h-[10%] flex flex-row justify-end items-center space-x-4"> <a href={PAGE_SIGNUP} className="font-lato text-white text-md bg-black px-6 py-1 rounded hover:cursor-pointer">Sign Up</a> <a href={PAGE_LOGIN} className="font-lato text-white text-md bg-green px-6 py-1 rounded hover:cursor-pointer">Log In</a> </aside> </section> </main> ) }