shadimage
Chanhdai
componentapple-carouselsource: ok · refreshed

Apple Carousel

Infinite autoplay carousel with progress dots, inspired by Apple’s website.

npx shadcn add "https://chanhdai.com/r/apple-carousel.json"
free

Interaction

carousel

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files1 · source cached 10/2/2026, 11:07:58 AM

src/registry/components/apple-carousel/apple-carousel.tsx
"use client"

import {
  Children,
  createContext,
  isValidElement,
  use,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  useSyncExternalStore,
} from "react"
import type { ComponentProps, KeyboardEvent, RefObject } from "react"
import {
  animate,
  motion,
  useInView,
  useMotionValue,
  useMotionValueEvent,
} from "motion/react"
import type { AnimationPlaybackControls, Transition } from "motion/react"

import { cn } from "@/lib/utils"

// More than anyone can fling past before the wrap runs, so the loop's seam
// never scrolls into view.
const CLONE_COUNT = 4

// Debounce for "scroll settled", and how long scroll events after our own
// writes are ignored.
const SETTLE_DELAY = 150

// Spring visualDuration (s), longer for long jumps so they don't whip past.
const SCROLL_BASE_DURATION = 0.5
const SCROLL_EXTRA_CARD_DURATION = 0.1
const SCROLL_MAX_DURATION = 0.9

const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"

function subscribeReducedMotion(callback: () => void) {
  const mql = window.matchMedia(REDUCED_MOTION_QUERY)
  mql.addEventListener("change", callback)
  return () => mql.removeEventListener("change", callback)
}

function usePrefersReducedMotion() {
  return useSyncExternalStore(
    subscribeReducedMotion,
    () => window.matchMedia(REDUCED_MOTION_QUERY).matches,
    () => false
  )
}

const subscribeNothing = () => () => {}

function useIsHydrated() {
  return useSyncExternalStore(
    subscribeNothing,
    () => true,
    () => false
  )
}

function getSlides(viewport: HTMLElement) {
  return Array.from(viewport.firstElementChild?.children ?? []) as HTMLElement[]
}

function getSnapLeft(viewport: HTMLElement, slide: HTMLElement) {
  if (getComputedStyle(slide).scrollSnapAlign.startsWith("start")) {
    const padding = parseFloat(
      getComputedStyle(viewport).scrollPaddingInlineStart
    )
    return slide.offsetLeft - (padding || 0)
  }

  return slide.offsetLeft - (viewport.clientWidth - slide.offsetWidth) / 2
}

function getNearestSlide(viewport: HTMLElement) {
  let nearest: HTMLElement | undefined
  let minDistance = Infinity

  for (const slide of getSlides(viewport)) {
    const distance = Math.abs(
      getSnapLeft(viewport, slide) - viewport.scrollLeft
    )
    if (distance < minDistance) {
      minDistance = distance
      nearest = slide
    }
  }

  return nearest
}

function getRealSlide(viewport: HTMLElement, index: number) {
  return getSlides(viewport).find(
    (slide) =>
      !slide.hasAttribute("data-clone") && slide.dataset.index === String(index)
  )
}

type AppleCarouselContextValue = {
  id: string
  index: number
  isPlaying: boolean
  prefersReducedMotion: boolean
  viewportRef: RefObject<HTMLDivElement | null>
  progressFillRef: RefObject<HTMLSpanElement | null>
  goTo: (index: number) => void
  play: () => void
  pause: () => void
}

const AppleCarouselContext = createContext<AppleCarouselContextValue | null>(
  null
)

function useAppleCarousel() {
  const context = use(AppleCarouselContext)
  if (!context) {
    throw new Error("AppleCarousel parts must be used within AppleCarouselRoot")
  }
  return context
}

type AppleCarouselItemState = {
  /** Position of the item among the items passed to `AppleCarouselContent`. */
  index: number
  /** Whether the item is the one being shown. */
  isCurrent: boolean
  /** Whether this copy is a clone rendered to keep the loop seamless. */
  isClone: boolean
}

const AppleCarouselItemContext = createContext<Omit<
  AppleCarouselItemState,
  "isCurrent"
> | null>(null)

/**
 * State of the enclosing item. Content also renders in loop clones, so check
 * `isClone` before starting media, fetching, or tracking impressions.
 */
function useAppleCarouselItem(): AppleCarouselItemState {
  const { index: currentIndex } = useAppleCarousel()
  const item = use(AppleCarouselItemContext)
  if (!item) {
    throw new Error(
      "useAppleCarouselItem must be used inside an item of AppleCarouselContent"
    )
  }

  return { ...item, isCurrent: item.index === currentIndex }
}

const AppleCarouselTabContext = createContext<{
  index: number
  count: number
} | null>(null)

// `auto` holds back under reduced motion. Pressing play is an explicit opt-in,
// so `playing` ignores that preference and cards jump instead of sliding.
type Playback = "auto" | "playing" | "paused"

type AppleCarouselRootProps = ComponentProps<"div"> & {
  /**
   * Time each card stays up during autoplay (ms).
   * @defaultValue 5000
   */
  duration?: number
}

function AppleCarouselRoot({
  duration = 5000,
  className,
  ...props
}: AppleCarouselRootProps) {
  const id = useId()
  const viewportRef = useRef<HTMLDivElement>(null)
  const progressFillRef = useRef<HTMLSpanElement>(null)

  const [index, setIndex] = useState(0)
  const [playback, setPlayback] = useState<Playback>("auto")
  const isInView = useInView(viewportRef, { amount: 0.25 })
  const prefersReducedMotion = usePrefersReducedMotion()
  const isPlaying =
    isInView &&
    (playback === "playing" || (playback === "auto" && !prefersReducedMotion))

  const ignoreScrollUntilRef = useRef(0)
  const scrollAnimationRef = useRef<AnimationPlaybackControls | null>(null)
  const progressAnimationRef = useRef<Animation | null>(null)
  const scrollLeft = useMotionValue(0)

  const play = () => setPlayback("playing")
  const pause = () => setPlayback("paused")

  const isOwnScroll = () =>
    scrollAnimationRef.current !== null ||
    performance.now() < ignoreScrollUntilRef.current

  const writeScrollLeft = (value: number) => {
    const viewport = viewportRef.current
    if (!viewport) return
    ignoreScrollUntilRef.current = performance.now() + SETTLE_DELAY
    viewport.scrollLeft = value
  }

  useMotionValueEvent(scrollLeft, "change", writeScrollLeft)

  const setScrollAnimation = (controls: AnimationPlaybackControls | null) => {
    scrollAnimationRef.current = controls
    // Mandatory snapping would pull every intermediate scrollLeft back.
    viewportRef.current?.toggleAttribute("data-animating", controls !== null)
  }

  const stopScrollAnimation = () => {
    scrollAnimationRef.current?.stop()
    setScrollAnimation(null)
  }

  const syncFromScroll = () => {
    // An animation already committed its destination, so the cards it passes
    // through must not flash their dots.
    if (scrollAnimationRef.current) return
    const viewport = viewportRef.current
    const slide = viewport && getNearestSlide(viewport)
    if (slide) setIndex(Number(slide.dataset.index))
  }

  // Clone and real card look identical, so the jump is invisible.
  const wrapFromClone = () => {
    const viewport = viewportRef.current
    const slide = viewport && getNearestSlide(viewport)
    if (!viewport || !slide?.hasAttribute("data-clone")) return

    const realSlide = getRealSlide(viewport, Number(slide.dataset.index))
    if (realSlide) writeScrollLeft(getSnapLeft(viewport, realSlide))
  }

  const scrollToSlide = (slide: HTMLElement) => {
    const viewport = viewportRef.current
    if (!viewport) return

    setIndex(Number(slide.dataset.index))

    const to = getSnapLeft(viewport, slide)
    const distance = Math.abs(to - viewport.scrollLeft)

    if (prefersReducedMotion || distance < 1) {
      stopScrollAnimation()
      writeScrollLeft(to)
      wrapFromClone()
      return
    }

    // A running spring keeps its velocity when retargeted; only an idle one
    // needs to pick up where the user left the scroll position.
    if (!scrollAnimationRef.current) scrollLeft.jump(viewport.scrollLeft)

    const cardsToTravel = Math.max(distance / slide.offsetWidth, 1)
    const controls = animate(scrollLeft, to, {
      type: "spring",
      bounce: 0,
      visualDuration: Math.min(
        SCROLL_BASE_DURATION + (cardsToTravel - 1) * SCROLL_EXTRA_CARD_DURATION,
        SCROLL_MAX_DURATION
      ),
      onComplete: () => {
        if (scrollAnimationRef.current !== controls) return
        setScrollAnimation(null)
        wrapFromClone()
      },
    })
    setScrollAnimation(controls)
  }

  const goTo = (next: number) => {
    const viewport = viewportRef.current
    const slide = viewport && getRealSlide(viewport, next)
    if (slide) scrollToSlide(slide)
  }

  const goNext = () => {
    const viewport = viewportRef.current
    const next = viewport && getNearestSlide(viewport)?.nextElementSibling
    if (next instanceof HTMLElement) scrollToSlide(next)
  }

  const onViewportScroll = useEffectEvent(() => {
    if (isPlaying && !isOwnScroll()) pause()
    syncFromScroll()
  })

  const onViewportSettled = useEffectEvent(() => {
    if (!isOwnScroll()) wrapFromClone()
  })

  const onViewportInput = useEffectEvent(() => {
    stopScrollAnimation()
    pause()
  })

  useEffect(() => {
    const viewport = viewportRef.current
    if (!viewport) return

    let frame: number | null = null
    let settleTimer: ReturnType<typeof setTimeout> | undefined

    const handleScroll = () => {
      if (frame === null) {
        frame = requestAnimationFrame(() => {
          frame = null
          onViewportScroll()
        })
      }
      // `scrollend` is not available everywhere, so a debounce backs it up.
      // Whichever fires first wraps; the other finds a real card and no-ops.
      clearTimeout(settleTimer)
      settleTimer = setTimeout(onViewportSettled, SETTLE_DELAY)
    }

    const handleWheel = (event: WheelEvent) => {
      // Vertical wheels scroll the page, not the gallery.
      if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) onViewportInput()
    }

    viewport.addEventListener("scroll", handleScroll, { passive: true })
    viewport.addEventListener("scrollend", onViewportSettled)
    viewport.addEventListener("pointerdown", onViewportInput)
    viewport.addEventListener("wheel", handleWheel, { passive: true })

    return () => {
      viewport.removeEventListener("scroll", handleScroll)
      viewport.removeEventListener("scrollend", onViewportSettled)
      viewport.removeEventListener("pointerdown", onViewportInput)
      viewport.removeEventListener("wheel", handleWheel)
      if (frame !== null) cancelAnimationFrame(frame)
      clearTimeout(settleTimer)
    }
  }, [])

  const onProgressFinish = useEffectEvent(goNext)

  // The progress fill doubles as the autoplay clock: composited, pauses in
  // place, no per-frame JS. Without a tab list it still runs, targeting nothing.
  useEffect(() => {
    const animation = new Animation(
      new KeyframeEffect(
        progressFillRef.current,
        [{ scale: "0 1" }, { scale: "1 1" }],
        { duration, fill: "forwards" }
      )
    )
    animation.onfinish = onProgressFinish
    progressAnimationRef.current = animation

    return () => {
      animation.cancel()
      progressAnimationRef.current = null
    }
  }, [index, duration])

  useEffect(() => {
    const animation = progressAnimationRef.current
    if (!animation) return
    if (isPlaying) animation.play()
    else animation.pause()
  }, [index, duration, isPlaying])

  return (
    <AppleCarouselContext
      value={{
        id,
        index,
        isPlaying,
        prefersReducedMotion,
        viewportRef,
        progressFillRef,
        goTo,
        play,
        pause,
      }}
    >
      <div
        data-slot="apple-carousel"
        data-playing={isPlaying ? "" : undefined}
        className={cn("@container relative w-full", className)}
        {...props}
      />
    </AppleCarouselContext>
  )
}

function AppleCarouselContent({
  className,
  children,
  ...props
}: ComponentProps<"div">) {
  const { index, viewportRef } = useAppleCarousel()
  const isHydrated = useIsHydrated()

  const items = Children.toArray(children).filter(isValidElement)
  const count = items.length
  // Clones wait for hydration: without them the first card already sits at
  // scrollLeft 0, so the server markup renders the right card with no script.
  const cloneCount = isHydrated && count > 1 ? Math.min(CLONE_COUNT, count) : 0

  const alignToCurrent = useEffectEvent(() => {
    const viewport = viewportRef.current
    const slide = viewport && getRealSlide(viewport, index)
    if (viewport && slide) viewport.scrollLeft = getSnapLeft(viewport, slide)
  })

  useLayoutEffect(() => {
    if (cloneCount > 0) alignToCurrent()
  }, [cloneCount])

  return (
    <div
      ref={viewportRef}
      data-slot="apple-carousel-content"
      className={cn(
        "relative no-scrollbar snap-x snap-mandatory overflow-x-auto overflow-y-hidden data-animating:snap-none",
        "[--apple-carousel-gap:--spacing(5)] [--apple-carousel-item-width:min(max(87.5cqw,var(--container-3xs)),var(--container-6xl))] [--apple-carousel-padding:max(6.25cqw,(100cqw-var(--container-6xl))/2)]",
        "@max-3xl:scroll-ps-(--apple-carousel-padding) @max-3xl:[--apple-carousel-item-width:calc(max(87.5cqw,var(--container-3xs))-var(--apple-carousel-gap))]",
        className
      )}
      {...props}
    >
      <ul
        role="list"
        data-slot="apple-carousel-list"
        className="mx-auto grid w-fit grid-flow-col gap-(--apple-carousel-gap) px-(--apple-carousel-padding)"
      >
        {items.slice(count - cloneCount).map((item, i) => (
          <AppleCarouselItemContext
            key={`${item.key}-leading`}
            value={{ index: count - cloneCount + i, isClone: true }}
          >
            {item}
          </AppleCarouselItemContext>
        ))}
        {items.map((item, i) => (
          <AppleCarouselItemContext
            key={item.key}
            value={{ index: i, isClone: false }}
          >
            {item}
          </AppleCarouselItemContext>
        ))}
        {items.slice(0, cloneCount).map((item, i) => (
          <AppleCarouselItemContext
            key={`${item.key}-trailing`}
            value={{ index: i, isClone: true }}
          >
            {item}
          </AppleCarouselItemContext>
        ))}
      </ul>
    </div>
  )
}

function AppleCarouselItem({ className, ...props }: ComponentProps<"li">) {
  const { id } = useAppleCarousel()
  const { index, isCurrent, isClone } = useAppleCarouselItem()

  return (
    <li
      id={isClone ? undefined : `${id}-item-${index}`}
      role={isClone ? undefined : "tabpanel"}
      aria-labelledby={isClone ? undefined : `${id}-tab-${index}`}
      // Peeking cards stay out of reach: a click there would otherwise follow
      // a link on a card the user isn't looking at.
      inert={isClone || !isCurrent}
      tabIndex={isCurrent && !isClone ? 0 : -1}
      data-slot="apple-carousel-item"
      data-index={index}
      data-clone={isClone ? "" : undefined}
      data-current={isCurrent ? "" : undefined}
      className={cn(
        "relative h-152 w-(--apple-carousel-item-width) snap-center overflow-hidden rounded-4xl bg-muted data-clone:snap-always",
        "@max-5xl:h-140 @max-3xl:h-152 @max-3xl:snap-start",
        "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
        className
      )}
      {...props}
    />
  )
}

type AppleCarouselControlsProps = ComponentProps<"div"> & {
  /**
   * Size of the tab list and play button inside.
   * @defaultValue "default"
   */
  size?: "default" | "sm"
}

function AppleCarouselControls({
  size = "default",
  className,
  ...props
}: AppleCarouselControlsProps) {
  return (
    <div
      data-slot="apple-carousel-controls"
      data-size={size}
      className={cn(
        "group/apple-carousel-controls flex items-center justify-center gap-3 pt-6 data-[size=sm]:gap-2 data-[size=sm]:pt-4",
        className
      )}
      {...props}
    />
  )
}

function AppleCarouselTabList({
  className,
  children,
  ...props
}: ComponentProps<"div">) {
  const tabs = Children.toArray(children).filter(isValidElement)

  return (
    <div
      role="tablist"
      data-slot="apple-carousel-tab-list"
      className={cn(
        "flex h-14 items-center rounded-full bg-muted px-4 group-data-[size=sm]/apple-carousel-controls:h-11 group-data-[size=sm]/apple-carousel-controls:px-3",
        className
      )}
      {...props}
    >
      {tabs.map((tab, index) => (
        <AppleCarouselTabContext
          key={tab.key}
          value={{ index, count: tabs.length }}
        >
          {tab}
        </AppleCarouselTabContext>
      ))}
    </div>
  )
}

function AppleCarouselTab({
  className,
  children,
  onClick,
  onFocus,
  onKeyDown,
  ...props
}: ComponentProps<"button">) {
  const {
    id,
    index: currentIndex,
    isPlaying,
    progressFillRef,
    goTo,
    pause,
  } = useAppleCarousel()
  const tab = use(AppleCarouselTabContext)
  if (!tab) {
    throw new Error(
      "AppleCarouselTab must be a direct child of AppleCarouselTabList"
    )
  }

  const { index, count } = tab
  const isCurrent = index === currentIndex

  const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    onKeyDown?.(event)
    if (event.defaultPrevented) return

    let next: number
    switch (event.key) {
      case "ArrowLeft":
        next = (index - 1 + count) % count
        break
      case "ArrowRight":
        next = (index + 1) % count
        break
      case "Home":
        next = 0
        break
      case "End":
        next = count - 1
        break
      default:
        return
    }

    event.preventDefault()
    goTo(next)
    event.currentTarget
      .closest('[role="tablist"]')
      ?.querySelectorAll<HTMLElement>('[role="tab"]')
      [next]?.focus()
  }

  return (
    <button
      type="button"
      role="tab"
      id={`${id}-tab-${index}`}
      aria-controls={`${id}-item-${index}`}
      aria-selected={isCurrent}
      tabIndex={isCurrent ? 0 : -1}
      data-slot="apple-carousel-tab"
      data-current={isCurrent ? "" : undefined}
      className={cn(
        "relative mx-2 size-2 overflow-hidden rounded-full bg-muted-foreground not-data-current:hover:bg-foreground",
        // Width morphs on screen; the hover color is a quick, small change.
        "transition-[width,background-color] duration-[400ms,150ms] ease-[cubic-bezier(0.645,0.045,0.355,1),ease]",
        "motion-reduce:transition-colors motion-reduce:duration-150 motion-reduce:ease-[ease]",
        "data-current:w-12 group-data-[size=sm]/apple-carousel-controls:data-current:w-6 @max-3xl:data-current:w-8",
        "focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-ring",
        className
      )}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) goTo(index)
      }}
      onFocus={(event) => {
        onFocus?.(event)
        if (isPlaying && event.currentTarget.matches(":focus-visible")) pause()
      }}
      onKeyDown={handleKeyDown}
      {...props}
    >
      <span
        ref={isCurrent ? progressFillRef : undefined}
        aria-hidden
        data-slot="apple-carousel-tab-progress"
        className="absolute inset-0 origin-left scale-x-0 rounded-[inherit] bg-foreground"
      />
      <span className="sr-only">{children}</span>
    </button>
  )
}

// Points sit half a stroke inside the outline, so the round joins soften the
// corners without growing the icon. Changing the width means re-insetting them.
const ICON_STROKE_WIDTH = 2.5

// Matching M L L L Z commands let motion morph `d`: the play triangle is split
// at x = 13, one half per pause bar.
const PAUSE_ICON = {
  left: "M5.5 5.5L8.5 5.5L8.5 18.5L5.5 18.5Z",
  right: "M15.5 5.5L18.5 5.5L18.5 18.5L15.5 18.5Z",
} as const

const PLAY_ICON = {
  left: "M7.5 5.87L13 9.08L13 14.92L7.5 18.13Z",
  right: "M13 9.08L18.01 12L18.01 12L13 14.92Z",
} as const

const ICON_TRANSITION: Transition = {
  type: "spring",
  visualDuration: 0.3,
  bounce: 0,
}

const INSTANT_TRANSITION: Transition = { duration: 0 }

function AppleCarouselPlayButton({
  className,
  onClick,
  ...props
}: ComponentProps<"button">) {
  const { isPlaying, prefersReducedMotion, play, pause } = useAppleCarousel()
  // The icon names the action, so a playing gallery shows pause.
  const icon = isPlaying ? PAUSE_ICON : PLAY_ICON
  const transition = prefersReducedMotion ? INSTANT_TRANSITION : ICON_TRANSITION

  return (
    <button
      type="button"
      aria-label={isPlaying ? "Pause gallery" : "Play gallery"}
      data-slot="apple-carousel-play-button"
      className={cn(
        "flex size-14 items-center justify-center rounded-full bg-muted text-foreground group-data-[size=sm]/apple-carousel-controls:size-11",
        "transition-[background-color,scale] duration-150 ease-out hover:bg-muted/80 active:scale-97 motion-reduce:active:scale-100",
        "focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-ring",
        className
      )}
      onClick={(event) => {
        onClick?.(event)
        if (event.defaultPrevented) return
        if (isPlaying) pause()
        else play()
      }}
      {...props}
    >
      <svg
        aria-hidden
        viewBox="0 0 24 24"
        fill="currentColor"
        stroke="currentColor"
        strokeWidth={ICON_STROKE_WIDTH}
        strokeLinejoin="round"
        className="size-6 group-data-[size=sm]/apple-carousel-controls:size-5"
      >
        <motion.path
          initial={false}
          animate={{ d: icon.left }}
          transition={transition}
        />
        <motion.path
          initial={false}
          animate={{ d: icon.right }}
          transition={transition}
        />
      </svg>
    </button>
  )
}

export {
  AppleCarouselContent,
  AppleCarouselControls,
  AppleCarouselItem,
  AppleCarouselPlayButton,
  AppleCarouselRoot,
  AppleCarouselTab,
  AppleCarouselTabList,
  useAppleCarouselItem,
}
export type {
  AppleCarouselControlsProps,
  AppleCarouselItemState,
  AppleCarouselRootProps,
}

More from Chanhdai

Browse all components in this library →