shadimage

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