Carousel
A center-snapping carousel primitive with responsive edge blur and button controls.
registry/corr/components/carousel.tsx
"use client"
import * as React from "react"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
type CarouselContextValue = {
scrollRef: React.RefObject<HTMLDivElement | null>
edgeSize: number
itemInset: { start: number; end: number }
edges: { left: boolean; right: boolean }
canScrollPrevious: boolean
canScrollNext: boolean
scrollToItem: (direction: "previous" | "next") => void
}
const CarouselContext = React.createContext<CarouselContextValue | null>(null)
const minimumItemInset = 16
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("Carousel components must be used within <Carousel />")
}
return context
}
export function Carousel({
children,
title,
description,
controls = true,
className,
contentClassName,
}: {
children: React.ReactNode
title?: string
description?: React.ReactNode
controls?: boolean
className?: string
contentClassName?: string
}) {
const scrollRef = React.useRef<HTMLDivElement>(null)
const reduceMotion = useReducedMotion()
const [edges, setEdges] = React.useState({ left: false, right: false })
const [edgeSize, setEdgeSize] = React.useState(48)
const [itemInset, setItemInset] = React.useState({ start: 16, end: 16 })
const updateEdges = React.useCallback(() => {
const element = scrollRef.current
if (!element) return
const items = Array.from(
element.querySelectorAll<HTMLElement>("[data-carousel-item]")
)
const firstItem = items[0]
const lastItem = items[items.length - 1]
const maxScroll = element.scrollWidth - element.clientWidth
setEdgeSize(Math.max(20, Math.min(64, element.clientWidth * 0.1)))
const shouldCenterFirstItem = firstItem
? firstItem.offsetWidth >= element.clientWidth * 0.5
: false
const shouldCenterLastItem = lastItem
? lastItem.offsetWidth >= element.clientWidth * 0.5
: false
const nextInset = {
start: firstItem && shouldCenterFirstItem
? Math.max(
minimumItemInset,
(element.clientWidth - firstItem.offsetWidth) / 2
)
: minimumItemInset,
end: lastItem && shouldCenterLastItem
? Math.max(
minimumItemInset,
(element.clientWidth - lastItem.offsetWidth) / 2
)
: minimumItemInset,
}
setItemInset((currentInset) => {
if (
Math.abs(currentInset.start - nextInset.start) < 1 &&
Math.abs(currentInset.end - nextInset.end) < 1
) {
return currentInset
}
return nextInset
})
setEdges({
left: element.scrollLeft > 4,
right: element.scrollLeft < maxScroll - 4,
})
}, [])
React.useEffect(() => {
const element = scrollRef.current
if (!element) return
updateEdges()
element.addEventListener("scroll", updateEdges, { passive: true })
window.addEventListener("resize", updateEdges)
const resizeObserver =
"ResizeObserver" in window
? new ResizeObserver(() => updateEdges())
: null
resizeObserver?.observe(element)
return () => {
element.removeEventListener("scroll", updateEdges)
window.removeEventListener("resize", updateEdges)
resizeObserver?.disconnect()
}
}, [updateEdges])
React.useEffect(() => {
const frame = window.requestAnimationFrame(updateEdges)
return () => window.cancelAnimationFrame(frame)
}, [itemInset, updateEdges])
const scrollToItem = React.useCallback(
(direction: "previous" | "next") => {
const element = scrollRef.current
if (!element) return
const items = Array.from(
element.querySelectorAll<HTMLElement>("[data-carousel-item]")
)
const scrollItems = items.length
? items
: Array.from(
element.firstElementChild?.children ?? []
).filter((item): item is HTMLElement => item instanceof HTMLElement)
if (!scrollItems.length) return
const viewportCenter = element.scrollLeft + element.clientWidth / 2
const currentIndex = scrollItems.reduce((closestIndex, item, index) => {
const itemCenter = item.offsetLeft + item.offsetWidth / 2
const closest = scrollItems[closestIndex]
const closestCenter = closest.offsetLeft + closest.offsetWidth / 2
return Math.abs(itemCenter - viewportCenter) <
Math.abs(closestCenter - viewportCenter)
? index
: closestIndex
}, 0)
const nextIndex =
direction === "next"
? Math.min(currentIndex + 1, scrollItems.length - 1)
: Math.max(currentIndex - 1, 0)
const nextItem = scrollItems[nextIndex]
const nextLeft =
nextItem.offsetLeft + nextItem.offsetWidth / 2 - element.clientWidth / 2
element.scrollTo({
left: nextLeft,
behavior: reduceMotion ? "auto" : "smooth",
})
},
[reduceMotion]
)
const context = React.useMemo<CarouselContextValue>(
() => ({
scrollRef,
edgeSize,
itemInset,
edges,
canScrollPrevious: edges.left,
canScrollNext: edges.right,
scrollToItem,
}),
[edgeSize, edges, itemInset, scrollToItem]
)
const hasHeader = title || description || controls
return (
<CarouselContext.Provider value={context}>
<div className={cn("rounded-md border bg-card", className)}>
{hasHeader ? (
<div className="flex flex-wrap items-start justify-between gap-3 border-b p-3">
<div className="min-w-0">
{title ? <h3 className="text-sm font-medium">{title}</h3> : null}
{description ? (
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{description}
</div>
) : null}
</div>
{controls ? (
<div className="flex items-center gap-1">
<CarouselPrevious />
<CarouselNext />
</div>
) : null}
</div>
) : null}
<div className={cn("relative overflow-hidden", contentClassName)}>
{children}
</div>
</div>
</CarouselContext.Provider>
)
}
export function CarouselContent({
children,
className,
"aria-label": ariaLabel = "Carousel",
}: React.ComponentProps<"div">) {
const { scrollRef, edges, edgeSize, itemInset } = useCarousel()
return (
<>
{edges.left ? <CarouselEdgeBlur side="left" width={edgeSize} /> : null}
{edges.right ? <CarouselEdgeBlur side="right" width={edgeSize} /> : null}
<div
ref={scrollRef}
aria-label={ariaLabel}
className="scrollbar-none overflow-x-auto scroll-smooth py-4"
style={{
scrollPaddingInlineStart: itemInset.start,
scrollPaddingInlineEnd: itemInset.end,
}}
>
<div
className={cn(
"flex snap-x snap-mandatory gap-3 [&>*]:snap-center",
className
)}
>
<div
aria-hidden="true"
className="shrink-0"
style={{ width: itemInset.start }}
/>
{children}
<div
aria-hidden="true"
className="shrink-0"
style={{ width: itemInset.end }}
/>
</div>
</div>
</>
)
}
export function CarouselItem({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-carousel-item
className={cn("shrink-0 snap-center", className)}
{...props}
/>
)
}
export function CarouselPrevious({
className,
...props
}: React.ComponentProps<typeof Button>) {
const { scrollToItem, canScrollPrevious } = useCarousel()
return (
<Button
type="button"
variant="outline"
size="icon-sm"
className={cn("rounded-full", className)}
onClick={() => scrollToItem("previous")}
disabled={!canScrollPrevious}
{...props}
>
<ArrowLeftIcon />
<span className="sr-only">Previous</span>
</Button>
)
}
export function CarouselNext({
className,
...props
}: React.ComponentProps<typeof Button>) {
const { scrollToItem, canScrollNext } = useCarousel()
return (
<Button
type="button"
variant="outline"
size="icon-sm"
className={cn("rounded-full", className)}
onClick={() => scrollToItem("next")}
disabled={!canScrollNext}
{...props}
>
<ArrowRightIcon />
<span className="sr-only">Next</span>
</Button>
)
}
export function CarouselEdgeBlur({
side,
width,
}: {
side: "left" | "right"
width: number
}) {
const isLeft = side === "left"
return (
<motion.div
className={cn(
"pointer-events-none absolute inset-y-0 z-10",
isLeft ? "left-0" : "right-0"
)}
style={{ width }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
>
<div
className={cn(
"absolute inset-0",
isLeft ? "bg-linear-to-r" : "bg-linear-to-l",
"from-card via-card/70 to-transparent"
)}
/>
<div
className={cn(
"absolute inset-0 backdrop-blur-[5px]",
isLeft
? "[mask-image:linear-gradient(to_right,black_0%,black_18%,transparent_100%)]"
: "[mask-image:linear-gradient(to_left,black_0%,black_18%,transparent_100%)]"
)}
/>
<div
className={cn(
"absolute inset-0 backdrop-blur-[10px]",
isLeft
? "[mask-image:linear-gradient(to_right,black_0%,transparent_76%)]"
: "[mask-image:linear-gradient(to_left,black_0%,transparent_76%)]"
)}
/>
</motion.div>
)
}
- Visual style
- glass-blur
- Interaction
- carousel
DependenciesBundle weight · medium
- icons
- lucide-react
- animation
- motion