Created
April 10, 2024 19:10
-
-
Save LawJolla/1293998807d414e883f22303240003b6 to your computer and use it in GitHub Desktop.
Carousel beta, let me know improvements!
This file contains 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 imgixLoader from "@/lib/imageLoader" | |
import { | |
ChevronRightIcon, | |
ExclamationTriangleIcon | |
} from "@heroicons/react/24/outline" | |
import { ChevronLeftIcon } from "@heroicons/react/24/solid" | |
import { AnimatePresence, motion, MotionConfig } from "framer-motion" | |
import NextImage from "next/image" | |
import React, { useRef, useState } from "react" | |
const swipeConfidenceThreshold = 10000 | |
const swipePower = (offset: number, velocity: number) => { | |
return Math.abs(offset) * velocity | |
} | |
// dot size | |
const selectedSize = 12 | |
const normalSize = 10 | |
const smallSize = 6 | |
const dotSize = ({ | |
selectedIndex, | |
imageIndex, | |
imageCount | |
}: { | |
selectedIndex: number | |
imageIndex: number | |
imageCount: number | |
}) => { | |
if (imageCount <= 5) { | |
return selectedIndex === imageIndex ? selectedSize : normalSize | |
} | |
if (selectedIndex === imageIndex) { | |
return selectedSize | |
} | |
// make dot to right of seclected smaller | |
if (selectedIndex + 1 === imageIndex || selectedIndex - 1 === imageIndex) { | |
return normalSize | |
} | |
// make dots to right of selected even smaller | |
if (selectedIndex + 2 >= imageIndex || selectedIndex - 2 <= imageIndex) { | |
return smallSize | |
} | |
return normalSize | |
} | |
type CalculateXShift = { | |
ref: React.RefObject<HTMLDivElement> | |
index: number | |
imageCount: number | |
} | |
const calulateXShift = ({ ref, index, imageCount }: CalculateXShift) => { | |
// get last dot off screen from ref | |
let selectedDot = 6 | |
if (index >= 3) { | |
selectedDot = index + 3 | |
} | |
selectedDot = Math.min(selectedDot, imageCount - 1) | |
const dotElement = ref.current?.children[selectedDot] | |
if (!dotElement) { | |
return 0 | |
} | |
const flexGap = parseFloat(window.getComputedStyle(ref.current).gap ?? 0) | |
let dotWidth = dotElement.clientWidth + flexGap | |
if (index >= imageCount - 2) { | |
dotWidth = selectedSize + flexGap | |
} | |
return -Math.min(Math.max(index - 2, 0), imageCount - 6) * dotWidth | |
} | |
type CalculateIsDotVisible = { | |
index: number | |
imageIndex: number | |
imageCount: number | |
} | |
const isDotVisible = ({ | |
index, | |
imageIndex, | |
imageCount | |
}: CalculateIsDotVisible) => { | |
// if selected is near the end | |
if (index + 2 >= imageCount) { | |
if (imageIndex >= imageCount - 5) { | |
return true | |
} | |
return false | |
} | |
// if selected is near the start | |
if (index <= 2) { | |
if (imageIndex >= 5) { | |
return false | |
} | |
return true | |
} | |
if (imageIndex > index + 2) { | |
return false | |
} | |
if (imageIndex < index - 2) { | |
return false | |
} | |
return true | |
} | |
const CarouselDots = ({ | |
images, | |
index, | |
setIndex | |
}: { | |
images: string[] | |
index: number | |
setIndex: React.Dispatch<React.SetStateAction<number>> | |
}) => { | |
const ref = useRef<HTMLDivElement>(null) | |
const xShift = calulateXShift({ ref, index, imageCount: images.length }) | |
return ( | |
<div className={`max-w-[90px] flex-1 overflow-hidden pl-2`}> | |
<motion.div | |
ref={ref} | |
initial={false} | |
className=" relative inline-flex items-center gap-2" | |
animate={{ | |
x: images.length > 5 ? xShift : 0 | |
}} | |
> | |
{images.map((_, i) => { | |
const size = dotSize({ | |
selectedIndex: index, | |
imageIndex: i, | |
imageCount: images.length | |
}) | |
return ( | |
<motion.button | |
initial={false} | |
style={{ | |
originY: "50%", | |
originX: "50%" | |
}} | |
key={i} | |
onClick={() => setIndex(i)} | |
animate={{ | |
// is within 2 of selected | |
opacity: isDotVisible({ | |
index, | |
imageIndex: i, | |
imageCount: images.length | |
}) | |
? 1 | |
: 0, | |
width: size, | |
height: size, | |
backgroundColor: | |
index === i ? "var(--primary-hex-500)" : "var(--mono-300)" | |
}} | |
className={`rounded-full `} | |
/> | |
) | |
})} | |
</motion.div> | |
</div> | |
) | |
} | |
const variants = { | |
enter: (direction: "increasing" | "decreasing") => { | |
return { | |
x: direction === "increasing" ? `70%` : `-70%`, | |
opacity: 0 | |
} | |
}, | |
center: { | |
scale: 1, | |
x: 0, | |
opacity: 1 | |
}, | |
exit: (direction: "increasing" | "decreasing") => { | |
return { | |
x: direction === "increasing" ? `-100%` : `100%`, | |
opacity: 0 | |
} | |
} | |
} | |
const usePreviousState = <T,>(state: T) => { | |
const [tuple, setTuple] = useState<[T, T]>([state, state]) // previous, current | |
if (state !== tuple[1]) { | |
setTuple([tuple[1], state]) | |
} | |
return tuple[0] | |
} | |
export function Carousel({ images }: { images: string[] }) { | |
const [index, setIndex] = useState(0) | |
const prev = usePreviousState(index) | |
const direction = index > prev ? "increasing" : "decreasing" | |
// todo, update for keypress | |
// useKeypress("ArrowRight", () => { | |
// if (index + 1 < images.length) { | |
// setIndex(index + 1); | |
// } | |
// }); | |
// useKeypress("ArrowLeft", () => { | |
// if (index > 0) { | |
// setIndex((i) => i - 1); | |
// } | |
// }); | |
return ( | |
<MotionConfig transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}> | |
<div className="h-full "> | |
<div className="mx-auto flex h-full max-w-7xl flex-col justify-center"> | |
<div className="relative overflow-hidden"> | |
<motion.div | |
drag="x" | |
dragConstraints={{ left: 0, right: 0 }} | |
dragElastic={1} | |
onDragEnd={(e, { offset, velocity }) => { | |
const swipe = swipePower(offset.x, velocity.x) | |
if (swipe < -swipeConfidenceThreshold) { | |
setIndex((v) => v + 1) | |
} else if (swipe > swipeConfidenceThreshold) { | |
setIndex((v) => v - 1) | |
} | |
}} | |
> | |
<AnimatePresence | |
initial={false} | |
mode={`popLayout`} | |
custom={direction} | |
> | |
<CarouselImage | |
direction={direction} | |
images={images} | |
key={index} | |
index={index} | |
/> | |
</AnimatePresence> | |
</motion.div> | |
</div> | |
<div className="flex items-center justify-between gap-2"> | |
<CarouselDots images={images} index={index} setIndex={setIndex} /> | |
<div className="flex justify-end gap-2"> | |
<motion.button | |
key="left" | |
initial={{ opacity: 0 }} | |
animate={{ opacity: index > 0 ? 0.7 : 0 }} | |
exit={{ opacity: 0, pointerEvents: "none" }} | |
className={`dark:text-primary-100 flex h-8 w-8 items-center justify-center rounded-full px-1 ${ | |
index <= 0 && ` pointer-events-none ` | |
}`} | |
onClick={() => setIndex((val) => val - 1)} | |
> | |
<ChevronLeftIcon className="h-6 w-6" /> | |
</motion.button> | |
<motion.button | |
key="right" | |
initial={{ opacity: 0 }} | |
animate={{ opacity: index + 1 < images.length ? 0.7 : 0 }} | |
exit={{ opacity: 0, pointerEvents: "none" }} | |
className={` dark:text-primary-100 flex h-8 w-8 items-center justify-center rounded-full px-1 ${ | |
index + 1 >= images.length && ` pointer-events-none ` | |
}`} | |
onClick={() => setIndex((val) => val + 1)} | |
> | |
<ChevronRightIcon className="h-6 w-6" /> | |
</motion.button> | |
</div> | |
</div> | |
</div> | |
</div> | |
</MotionConfig> | |
) | |
} | |
const CarouselImage = ({ | |
images, | |
direction, | |
index, | |
preloadInitialImage | |
}: { | |
images: string[] | |
direction: "increasing" | "decreasing" | |
index: number | |
preloadInitialImage?: boolean | |
}) => { | |
// check if image is loaded | |
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading") | |
const shouldPreload = index !== 0 || preloadInitialImage | |
const preloadImage = shouldPreload | |
? images[ | |
Math.max( | |
Math.min( | |
direction === "increasing" ? index + 1 : index - 1, | |
images.length - 1 | |
), | |
0 | |
) | |
] | |
: null | |
return ( | |
<> | |
<motion.div | |
custom={direction} | |
variants={variants} | |
initial="enter" | |
animate="center" | |
exit="exit" | |
className={ | |
" relative inline-flex aspect-[3/2] h-full w-full items-center justify-center " | |
} | |
> | |
{status === "loading" && <Loading />} | |
{status === "error" && <ImageError />} | |
<NextImage | |
alt={`car img`} | |
fill={true} | |
loader={imgixLoader} | |
onLoad={() => setStatus("ready")} | |
onError={(e) => { | |
setStatus("error") | |
}} | |
sizes="(max-width: 500px) 100vw, (max-width: 1000px) 50vw, 33vw" | |
key={images[index]} | |
src={images[index]} | |
className="pointer-events-none aspect-[3/2] max-h-full max-w-full rounded-md object-contain px-4" | |
/> | |
</motion.div> | |
{ | |
// preload next image if the carousel is touched. Done this way instead of a useEffect to ensure the correct image size is loaded via next loader | |
} | |
{preloadImage && ( | |
<NextImage | |
alt={`car img`} | |
fill={true} | |
loader={imgixLoader} | |
onLoad={() => setStatus("ready")} | |
onError={() => { | |
setStatus("error") | |
}} | |
sizes="(max-width: 500px) 100vw, (max-width: 1000px) 50vw, 33vw" | |
key={preloadImage} | |
src={preloadImage} | |
className="pointer-events-none absolute z-0 opacity-0" | |
/> | |
)} | |
</> | |
) | |
} | |
const Loading = () => ( | |
<motion.div | |
variants={variants} | |
className="bg-mono-200 dark:bg-mono-900 pointer-events-none absolute inset-0 z-10 w-full animate-pulse rounded-md object-cover " | |
/> | |
) | |
const ImageError = () => ( | |
<div className=" dark:bg-red-950 pointer-events-none absolute inset-0 z-10 flex w-full items-center justify-center gap-x-4 rounded-md bg-red-200 object-cover text-red-800 dark:text-red-200"> | |
<ExclamationTriangleIcon className="h-6 w-6" /> | |
<span>Error loading image</span> | |
</div> | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment