组件
轮播 Carousel
基于原生 CSS 滚动与 scroll-snap 吸附机制构建的轮播组件,支持横向/纵向滑动、循环、自动播放与多列响应式排版。
基础用法
单卡片横向轮播。可通过前后按钮、触控手势、方向键或底部页码指示器浏览;当前页的圆点会拉伸为胶囊并滑动到目标位置:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/carousel安装基础依赖与图标库
pnpm add motion lucide-react clsx tailwind-merge复制组件源码到
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
Carousel (根容器)
继承原生 <div> 元素的全部 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| orientation | "horizontal" | "vertical" | "horizontal" | 轮播的滚动方向。'horizontal' 为横向滑动;'vertical' 为纵向翻页。 |
| loop | boolean | false | 到达边界末项时是否允许无限循环回到起始项。 |
| defaultIndex | number | 0 | 初始化时默认定位展示的项目索引(0-indexed)。 |
| onIndexChange | (index: number) => void | — | 当前展示的轮播卡片索引发生改变时的回调函数。 |
| autoplay | boolean | number | false | 自动播放。传入数字时为每页停留的毫秒数,true 为 5000ms。指针悬停、键盘焦点位于轮播内或页面隐藏时自动暂停;到达末页后回到第一页。 |
| className | string | — | 应用于外层轮播包裹容器的额外 CSS 类名。 |
CarouselContent
承载所有卡片的滚动视口容器:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于滚动视口的额外 CSS 类名(如重置边距或调整固定高度)。 |
CarouselItem
单个轮播卡片容器:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 通过 Tailwind 的 basis-* 类名控制一屏显示的卡片数量(例如 basis-full 为单卡片,md:basis-1/2 为一屏两卡,lg:basis-1/3 为一屏三卡)。 |
| data-active | attribute | — | 项目有一半以上位于视口内时自动添加,可通过 data-[active]: 变体为可见项目设置样式(如非当前项降低不透明度)。 |
CarouselPrevious / CarouselNext
翻页控制按钮:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于翻页按钮的额外 CSS 类名(用于覆盖默认定位或图标样式)。 |
CarouselDots
页码指示器,每个圆点对应一个吸附位置(一屏多卡时按实际可停靠的页数生成)。当前页圆点拉伸为胶囊并以弹簧动效在圆点间滑动;开启 autoplay 时,胶囊内会显示本页的停留进度。少于两页时不渲染。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于指示器容器的额外 CSS 类名。纵向轮播时圆点自动纵向排列。 |
useCarousel
在 Carousel 内部读取轮播状态,用于构建计数器、同步文案等自定义部件:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| currentIndex | number | — | 当前页索引。 |
| count | number | — | 可停靠的页数。 |
| scrollTo | (index: number) => void | — | 滚动到指定页。 |
| scrollPrevious / scrollNext | () => void | — | 切换到上一页 / 下一页,遵循 loop 设置。 |
| canScrollPrevious / canScrollNext | boolean | — | 当前是否可以继续向前 / 向后切换。 |
| progress | MotionValue<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,避免读屏软件频繁播报。 - 减少动态效果:系统开启“减少动态效果”时,翻页改为瞬时跳转,指示器不再做弹簧过渡。








