wui
组件

轮播 Carousel

基于原生 CSS 滚动与 scroll-snap 吸附机制构建的轮播组件,支持横向/纵向滑动、循环、自动播放与多列响应式排版。

第三方依赖 · lucide-react第三方依赖 · motion

基础用法

单卡片横向轮播。可通过前后按钮、触控手势、方向键或底部页码指示器浏览;当前页的圆点会拉伸为胶囊并滑动到目标位置:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/carousel
安装基础依赖与图标库
pnpm add motion lucide-react clsx tailwind-merge
复制组件源码到 components/ui/carousel.tsx
components/ui/carousel.tsx
"use client"

import * as React from "react"
import {
  ChevronDownIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
  ChevronUpIcon,
} from "lucide-react"
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  type AnimationPlaybackControls,
  type MotionValue,
} from "motion/react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

const SPRING = { type: "spring", stiffness: 520, damping: 38, mass: 0.7 } as const

type CarouselContextValue = {
  orientation: "horizontal" | "vertical"
  /** Index of the snap position currently aligned with the viewport start. */
  currentIndex: number
  /** Number of distinct snap positions (pages). */
  count: number
  /** Whether autoplay is enabled. */
  autoplay: boolean
  /** Autoplay progress of the current page, from 0 to 1. */
  progress: MotionValue<number>
  layoutId: string
  setViewport: (viewport: HTMLDivElement | null) => void
  updateState: () => void
  scrollTo: (index: number) => void
  scrollPrevious: () => void
  scrollNext: () => void
  canScrollPrevious: boolean
  canScrollNext: boolean
}

const CarouselContext = React.createContext<CarouselContextValue | null>(null)

/** 读取最近的 Carousel 状态,可用于构建计数器等自定义指示器。 */
function useCarousel() {
  const context = React.useContext(CarouselContext)
  if (!context) throw new Error("Carousel 子组件必须在 <Carousel> 内使用。")
  return context
}

export interface CarouselProps extends React.ComponentProps<"div"> {
  /** 轮播内容的滚动方向。@default "horizontal" */
  orientation?: "horizontal" | "vertical"
  /** 到达末端后是否允许循环到另一端。@default false */
  loop?: boolean
  /** 初次渲染时显示的项目索引。@default 0 */
  defaultIndex?: number
  /** 当前项目变化时触发。 */
  onIndexChange?: (index: number) => void
  /**
   * 自动播放。传入数字时作为每页停留的毫秒数,`true` 为 5000ms。
   * 指针悬停、键盘焦点位于轮播内或页面隐藏时自动暂停;到达末页后回到第一页。
   * @default false
   */
  autoplay?: boolean | number
}

/** 使用原生滚动和 scroll-snap 展示一组可逐项浏览的内容。 */
function Carousel({
  className,
  orientation = "horizontal",
  loop = false,
  defaultIndex = 0,
  onIndexChange,
  autoplay = false,
  onKeyDown,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  children,
  ...props
}: CarouselProps) {
  const reduceMotion = useReducedMotion()
  const layoutId = React.useId()
  const [viewport, setViewport] = React.useState<HTMLDivElement | null>(null)
  const [currentIndex, setCurrentIndex] = React.useState(defaultIndex)
  const [snapPoints, setSnapPoints] = React.useState<number[]>([])
  const [hovered, setHovered] = React.useState(false)
  const [focused, setFocused] = React.useState(false)
  const [documentHidden, setDocumentHidden] = React.useState(false)
  const initializedRef = React.useRef(false)
  const currentIndexRef = React.useRef(defaultIndex)
  const onIndexChangeRef = React.useRef(onIndexChange)
  const progress = useMotionValue(0)
  const autoplayControls = React.useRef<AnimationPlaybackControls | null>(null)
  const count = snapPoints.length
  const autoplayDelay =
    autoplay === true ? 5000 : typeof autoplay === "number" ? autoplay : 0
  const autoplayPaused = hovered || focused || documentHidden

  React.useEffect(() => {
    onIndexChangeRef.current = onIndexChange
  }, [onIndexChange])

  const getSnapPoints = React.useCallback(() => {
    if (!viewport) return []
    const horizontal = orientation === "horizontal"
    const maxScroll = horizontal
      ? viewport.scrollWidth - viewport.clientWidth
      : viewport.scrollHeight - viewport.clientHeight
    const points: number[] = []
    for (const item of Array.from(viewport.children) as HTMLElement[]) {
      const offset = horizontal ? item.offsetLeft : item.offsetTop
      const point = Math.min(Math.max(offset, 0), maxScroll)
      if (!points.some((existing) => Math.abs(existing - point) < 2)) {
        points.push(point)
      }
    }
    return points
  }, [orientation, viewport])

  const updateState = React.useCallback(() => {
    if (!viewport) return
    const horizontal = orientation === "horizontal"
    const points = getSnapPoints()
    const position = horizontal ? viewport.scrollLeft : viewport.scrollTop
    const nextIndex = points.reduce(
      (closest, point, index) =>
        Math.abs(point - position) < Math.abs(points[closest] - position)
          ? index
          : closest,
      0
    )

    // Mark slides that are mostly inside the viewport so they can be styled
    // with `data-[active]:` without re-rendering every item on scroll.
    const size = horizontal ? viewport.clientWidth : viewport.clientHeight
    for (const item of Array.from(viewport.children) as HTMLElement[]) {
      const start = (horizontal ? item.offsetLeft : item.offsetTop) - position
      const length = horizontal ? item.offsetWidth : item.offsetHeight
      const visible = Math.min(start + length, size) - Math.max(start, 0)
      item.toggleAttribute("data-active", visible > length / 2)
    }

    setSnapPoints((current) =>
      current.length === points.length &&
      current.every((point, index) => point === points[index])
        ? current
        : points
    )
    if (nextIndex !== currentIndexRef.current) {
      currentIndexRef.current = nextIndex
      setCurrentIndex(nextIndex)
      onIndexChangeRef.current?.(nextIndex)
    }
  }, [getSnapPoints, orientation, viewport])

  const scrollTo = React.useCallback(
    (index: number, behavior?: ScrollBehavior) => {
      if (!viewport) return
      const point = getSnapPoints()[index]
      if (point === undefined) return
      viewport.scrollTo({
        [orientation === "horizontal" ? "left" : "top"]: point,
        behavior: behavior ?? (reduceMotion ? "auto" : "smooth"),
      })
    },
    [getSnapPoints, orientation, reduceMotion, viewport]
  )

  React.useLayoutEffect(() => {
    if (!viewport) return
    if (!initializedRef.current) {
      initializedRef.current = true
      scrollTo(defaultIndex, "auto")
    }
    updateState()
    const resizeObserver = new ResizeObserver(updateState)
    resizeObserver.observe(viewport)
    for (const item of Array.from(viewport.children)) {
      resizeObserver.observe(item)
    }
    const mutationObserver = new MutationObserver((records) => {
      for (const record of records) {
        for (const node of Array.from(record.addedNodes)) {
          if (node instanceof Element) resizeObserver.observe(node)
        }
      }
      updateState()
    })
    mutationObserver.observe(viewport, { childList: true })
    return () => {
      resizeObserver.disconnect()
      mutationObserver.disconnect()
    }
  }, [defaultIndex, scrollTo, updateState, viewport])

  React.useEffect(() => {
    function handleVisibilityChange() {
      setDocumentHidden(document.visibilityState === "hidden")
    }
    document.addEventListener("visibilitychange", handleVisibilityChange)
    return () =>
      document.removeEventListener("visibilitychange", handleVisibilityChange)
  }, [])

  // One linear tween drives both the timer and the progress fill in
  // CarouselDots, so the visual indicator can never drift from the timer.
  React.useEffect(() => {
    progress.set(0)
    if (!autoplayDelay || count < 2) return
    const controls = animate(progress, 1, {
      duration: autoplayDelay / 1000,
      ease: "linear",
      onComplete: () => scrollTo((currentIndex + 1) % count),
    })
    autoplayControls.current = controls
    return () => {
      controls.stop()
      autoplayControls.current = null
    }
  }, [autoplayDelay, count, currentIndex, progress, scrollTo])

  React.useEffect(() => {
    if (autoplayPaused) autoplayControls.current?.pause()
    else autoplayControls.current?.play()
  }, [autoplayPaused, currentIndex, count])

  const canScrollPrevious = count > 1 && (loop || currentIndex > 0)
  const canScrollNext = count > 1 && (loop || currentIndex < count - 1)

  function scrollPrevious() {
    if (!canScrollPrevious) return
    scrollTo(currentIndex > 0 ? currentIndex - 1 : count - 1)
  }

  function scrollNext() {
    if (!canScrollNext) return
    scrollTo(currentIndex < count - 1 ? currentIndex + 1 : 0)
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event)
    if (event.defaultPrevented) return
    const target = event.target as HTMLElement
    if (target.matches("input, textarea, select, [contenteditable=true]")) {
      return
    }
    const previousKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp"
    const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown"
    if (event.key === previousKey) {
      event.preventDefault()
      scrollPrevious()
    } else if (event.key === nextKey) {
      event.preventDefault()
      scrollNext()
    }
  }

  return (
    <CarouselContext.Provider
      value={{
        orientation,
        currentIndex,
        count,
        autoplay: autoplayDelay > 0,
        progress,
        layoutId,
        setViewport,
        updateState,
        scrollTo,
        scrollPrevious,
        scrollNext,
        canScrollPrevious,
        canScrollNext,
      }}
    >
      <div
        data-slot="carousel"
        data-orientation={orientation}
        data-paused={autoplayDelay && autoplayPaused ? "" : undefined}
        role="region"
        aria-roledescription="carousel"
        className={cn("relative", className)}
        onKeyDown={handleKeyDown}
        onPointerEnter={(event) => {
          onPointerEnter?.(event)
          if (event.pointerType === "mouse") setHovered(true)
        }}
        onPointerLeave={(event) => {
          onPointerLeave?.(event)
          setHovered(false)
        }}
        onFocus={(event) => {
          onFocus?.(event)
          if ((event.target as HTMLElement).matches(":focus-visible")) {
            setFocused(true)
          }
        }}
        onBlur={(event) => {
          onBlur?.(event)
          if (!event.currentTarget.contains(event.relatedTarget)) {
            setFocused(false)
          }
        }}
        {...props}
      >
        {children}
      </div>
    </CarouselContext.Provider>
  )
}

/** 轮播项目的可滚动视口。 */
function CarouselContent({
  className,
  onScroll,
  ...props
}: React.ComponentProps<"div">) {
  const { orientation, autoplay, setViewport, updateState } = useCarousel()

  return (
    <div
      ref={setViewport}
      data-slot="carousel-content"
      aria-live={autoplay ? "off" : "polite"}
      className={cn(
        "relative flex overscroll-contain scroll-smooth [scrollbar-width:none] motion-reduce:scroll-auto [&::-webkit-scrollbar]:hidden",
        orientation === "horizontal"
          ? "snap-x snap-mandatory overflow-x-auto overflow-y-hidden"
          : "h-72 snap-y snap-mandatory flex-col overflow-y-auto overflow-x-hidden",
        className
      )}
      onScroll={(event) => {
        onScroll?.(event)
        if (!event.defaultPrevented) updateState()
      }}
      {...props}
    />
  )
}

/**
 * 单个轮播项目。位于视口内的项目会带上 `data-active` 属性,
 * 可使用 `data-[active]:` 变体为当前项目设置样式。
 */
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
  const { orientation } = useCarousel()

  return (
    <div
      data-slot="carousel-item"
      role="group"
      aria-roledescription="slide"
      className={cn(
        "min-w-0 shrink-0 snap-start",
        orientation === "horizontal" ? "basis-full" : "min-h-full basis-full",
        className
      )}
      {...props}
    />
  )
}

/** 移动到上一个轮播项目。 */
function CarouselPrevious({
  className,
  onClick,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { orientation, scrollPrevious, canScrollPrevious } = useCarousel()
  const Icon = orientation === "horizontal" ? ChevronLeftIcon : ChevronUpIcon

  return (
    <Button
      type="button"
      data-slot="carousel-previous"
      variant="outline"
      size="icon"
      aria-label="上一项"
      disabled={!canScrollPrevious}
      className={cn(
        "absolute z-10 rounded-full",
        orientation === "horizontal"
          ? "left-2 top-1/2 -translate-y-1/2"
          : "left-1/2 top-2 -translate-x-1/2",
        className
      )}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) scrollPrevious()
      }}
      {...props}
    >
      <Icon />
    </Button>
  )
}

/** 移动到下一个轮播项目。 */
function CarouselNext({
  className,
  onClick,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { orientation, scrollNext, canScrollNext } = useCarousel()
  const Icon = orientation === "horizontal" ? ChevronRightIcon : ChevronDownIcon

  return (
    <Button
      type="button"
      data-slot="carousel-next"
      variant="outline"
      size="icon"
      aria-label="下一项"
      disabled={!canScrollNext}
      className={cn(
        "absolute z-10 rounded-full",
        orientation === "horizontal"
          ? "right-2 top-1/2 -translate-y-1/2"
          : "bottom-2 left-1/2 -translate-x-1/2",
        className
      )}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) scrollNext()
      }}
      {...props}
    >
      <Icon />
    </Button>
  )
}

/**
 * 页码指示器。当前页的圆点拉伸为胶囊并在圆点间滑动;
 * 开启 `autoplay` 时,胶囊内会显示本页的播放进度。
 */
function CarouselDots({ className, ...props }: React.ComponentProps<"div">) {
  const {
    orientation,
    currentIndex,
    count,
    autoplay,
    progress,
    layoutId,
    scrollTo,
  } = useCarousel()
  const reduceMotion = useReducedMotion()
  const horizontal = orientation === "horizontal"
  const transition = reduceMotion ? { duration: 0 } : SPRING

  if (count < 2) return null

  return (
    <div
      data-slot="carousel-dots"
      role="group"
      aria-label="选择页面"
      className={cn(
        "flex items-center justify-center",
        horizontal ? "flex-row" : "flex-col",
        className
      )}
      {...props}
    >
      {Array.from({ length: count }, (_, index) => {
        const active = index === currentIndex
        return (
          <button
            key={index}
            type="button"
            data-slot="carousel-dot"
            data-active={active ? "" : undefined}
            aria-label={`第 ${index + 1} 页,共 ${count} 页`}
            aria-current={active ? "true" : undefined}
            className={cn(
              "group/dot flex items-center justify-center rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40",
              horizontal ? "h-6 px-1" : "w-6 py-1"
            )}
            onClick={() => scrollTo(index)}
          >
            <motion.span
              layout
              transition={transition}
              style={{ borderRadius: 999 }}
              className={cn(
                "relative block overflow-hidden bg-foreground/20 transition-colors group-hover/dot:bg-foreground/35",
                horizontal
                  ? cn("h-1.5", active ? "w-5" : "w-1.5")
                  : cn("w-1.5", active ? "h-5" : "h-1.5")
              )}
            >
              {active ? (
                <motion.span
                  layoutId={`${layoutId}-carousel-dot`}
                  transition={transition}
                  style={{ borderRadius: 999 }}
                  className={cn(
                    "absolute inset-0 overflow-hidden",
                    autoplay ? "bg-primary/30" : "bg-primary"
                  )}
                >
                  {autoplay ? (
                    <motion.span
                      className={cn(
                        "absolute inset-0 bg-primary",
                        horizontal ? "origin-left" : "origin-top"
                      )}
                      style={horizontal ? { scaleX: progress } : { scaleY: progress }}
                    />
                  ) : null}
                </motion.span>
              ) : null}
            </motion.span>
          </button>
        )
      })}
    </div>
  )
}

export {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
  useCarousel,
}

属性 Props

继承原生 <div> 元素的全部 HTML 属性:

属性类型默认值说明
orientation"horizontal" | "vertical""horizontal"轮播的滚动方向。'horizontal' 为横向滑动;'vertical' 为纵向翻页。
loopbooleanfalse到达边界末项时是否允许无限循环回到起始项。
defaultIndexnumber0初始化时默认定位展示的项目索引(0-indexed)。
onIndexChange(index: number) => void—当前展示的轮播卡片索引发生改变时的回调函数。
autoplayboolean | numberfalse自动播放。传入数字时为每页停留的毫秒数,true 为 5000ms。指针悬停、键盘焦点位于轮播内或页面隐藏时自动暂停;到达末页后回到第一页。
classNamestring—应用于外层轮播包裹容器的额外 CSS 类名。

CarouselContent

承载所有卡片的滚动视口容器:

属性类型默认值说明
classNamestring—应用于滚动视口的额外 CSS 类名(如重置边距或调整固定高度)。

CarouselItem

单个轮播卡片容器:

属性类型默认值说明
classNamestring—通过 Tailwind 的 basis-* 类名控制一屏显示的卡片数量(例如 basis-full 为单卡片,md:basis-1/2 为一屏两卡,lg:basis-1/3 为一屏三卡)。
data-activeattribute—项目有一半以上位于视口内时自动添加,可通过 data-[active]: 变体为可见项目设置样式(如非当前项降低不透明度)。

CarouselPrevious / CarouselNext

翻页控制按钮:

属性类型默认值说明
classNamestring—应用于翻页按钮的额外 CSS 类名(用于覆盖默认定位或图标样式)。

CarouselDots

页码指示器,每个圆点对应一个吸附位置(一屏多卡时按实际可停靠的页数生成)。当前页圆点拉伸为胶囊并以弹簧动效在圆点间滑动;开启 autoplay 时,胶囊内会显示本页的停留进度。少于两页时不渲染。

属性类型默认值说明
classNamestring—应用于指示器容器的额外 CSS 类名。纵向轮播时圆点自动纵向排列。

useCarousel

在 Carousel 内部读取轮播状态,用于构建计数器、同步文案等自定义部件:

属性类型默认值说明
currentIndexnumber—当前页索引。
countnumber—可停靠的页数。
scrollTo(index: number) => void—滚动到指定页。
scrollPrevious / scrollNext() => void—切换到上一页 / 下一页,遵循 loop 设置。
canScrollPrevious / canScrollNextboolean—当前是否可以继续向前 / 向后切换。
progressMotionValue<number>—自动播放时本页的停留进度(0–1),未开启时恒为 0。

事件 Events

属性类型默认值说明
onIndexChange(index: number) => void—轮播吸附到新的卡片项目后触发,参数为最新的活动项下标索引。
onScroll(event: React.UIEvent<HTMLDivElement>) => void—视口发生原生滚动时触发。

使用场景与设计规范

Carousel 适用于在有限的视口高度内浏览同质化的系列卡片或媒体:

  • 原生性能与手势:组件基于纯 CSS scroll-snap-type 与 overflow-x: auto,在触控屏、Mac 触控板双指滑动与鼠标滚轮上具备极致的原生 120fps 顺滑度,不额外劫持手势。
  • 一屏多卡展示:通过给 CarouselItem 设置 className="basis-full sm:basis-1/2 lg:basis-1/3",可轻松实现响应式多列轮播布局。
  • 重要信息避免隐藏:核心关键操作或阻断性信息不建议仅放在轮播的第 2 页之后,因为多数用户可能不会主动滑动至末尾。

场景示例

多卡片响应式轮播 (Multi-card)

结合响应式多卡(sm:basis-1/2 lg:basis-1/3)与循环(loop),翻页按钮移入标题栏;滑动过程中半露出的卡片通过 data-[active]: 变体淡出:

Loading…

自动播放 (Autoplay)

设置 autoplay 后指示器胶囊内显示停留进度,悬停时暂停。配合 useCarousel 读取当前页,让左侧文案随图片同步切换:

Loading…

垂直方向轮播 (Vertical)

配置 orientation="vertical" 实现纵向滚动的公告栏,结合 autoplay 与纵向页码指示器:

Loading…

无障碍与交互 Accessibility

  • WAI-ARIA Carousel Pattern:根容器标注 role="region" 与 aria-roledescription="carousel";每个卡片项标注 role="group" 与 aria-roledescription="slide"。
  • 按键与焦点管理:焦点位于轮播内时,可用 ← / →(纵向为 ↑ / ↓)切换。翻页按钮具备 aria-label="上一项" 与 aria-label="下一项",并在到达首末边界且未开启 loop 时自动 disabled。
  • 页码指示器:每个圆点标注“第 n 页,共 m 页”,当前页带有 aria-current。
  • 自动播放:键盘焦点进入或指针悬停时暂停;开启自动播放时视口 aria-live 为 off,避免读屏软件频繁播报。
  • 减少动态效果:系统开启“减少动态效果”时,翻页改为瞬时跳转,指示器不再做弹簧过渡。