Snap Carousel component
Install
npx shadcn add https://ui.happenings.social/r/snap-carousel.json
Or with namespace
npx shadcn add @happenings/snap-carousel
Preview
Dependencies
Registry dependencies
src/components/ui/snap-carousel.tsx
"use client"
import * as React from "react"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-solid-svg-icons"
import { cn } from "@/lib/utils"
interface SnapCarouselProps extends React.ComponentProps<"div"> {
/** Width of each item in px (used to calculate scroll distance). Default 224. */
itemWidth?: number
/** Gap between items in px. Default 12. */
gap?: number
}
function SnapCarousel({
children,
className,
itemWidth = 224,
gap = 12,
...props
}: SnapCarouselProps) {
const scrollRef = React.useRef<HTMLDivElement | null>(null)
const [canLeft, setCanLeft] = React.useState(false)
const [canRight, setCanRight] = React.useState(false)
const checkScroll = React.useCallback(() => {
const el = scrollRef.current
if (!el) return
setCanLeft(el.scrollLeft > 4)
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
}, [])
React.useEffect(() => {
checkScroll()
const el = scrollRef.current
if (!el) return
const obs = new ResizeObserver(checkScroll)
obs.observe(el)
return () => obs.disconnect()
}, [checkScroll, children])
const scroll = React.useCallback(
(direction: -1 | 1) => {
const el = scrollRef.current
if (!el) return
const visible = Math.max(1, Math.floor((el.clientWidth + gap) / (itemWidth + gap)))
el.scrollBy({ left: direction * visible * (itemWidth + gap), behavior: "smooth" })
},
[itemWidth, gap],
)
return (
<div className={cn("group/snap relative", className)} {...props}>
<div
ref={scrollRef}
onScroll={checkScroll}
className="flex overflow-x-auto scrollbar-none"
style={{ gap, scrollSnapType: "x mandatory" }}
>
{React.Children.map(children, (child) =>
React.isValidElement(child) ? (
<div style={{ scrollSnapAlign: "start" }}>{child}</div>
) : (
child
),
)}
</div>
{canLeft && (
<button
type="button"
onClick={() => scroll(-1)}
className="absolute left-0 top-1/2 z-10 flex size-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-zinc-200 bg-white/90 shadow-sm backdrop-blur-sm transition-all hover:bg-zinc-50 active:scale-95 dark:border-zinc-700 dark:bg-zinc-800/90 dark:hover:bg-zinc-700"
>
<FontAwesomeIcon icon={faChevronLeft} className="size-3" />
</button>
)}
{canRight && (
<button
type="button"
onClick={() => scroll(1)}
className="absolute right-0 top-1/2 z-10 flex size-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-zinc-200 bg-white/90 shadow-sm backdrop-blur-sm transition-all hover:bg-zinc-50 active:scale-95 dark:border-zinc-700 dark:bg-zinc-800/90 dark:hover:bg-zinc-700"
>
<FontAwesomeIcon icon={faChevronRight} className="size-3" />
</button>
)}
</div>
)
}
export { SnapCarousel, type SnapCarouselProps }