image-carousel
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseImage Carousel Pattern
图片轮播组件模式
Build smooth image carousels that auto-advance on hover with touch swipe support and animated progress indicators.
构建支持悬停自动切换、触摸滑动以及动画进度指示器的流畅图片轮播组件。
Core Features
核心特性
- Hover-activated: Auto-advance starts only when user hovers (not on page load)
- Touch swipe: Mobile-friendly swipe navigation with threshold detection
- Progress indicators: Glassmorphic pill indicators with animated fill
- Pause on interaction: Manual navigation pauses auto-advance temporarily
- 悬停触发:仅当用户悬停时启动自动切换(页面加载时不启动)
- 触摸滑动:支持移动端滑动导航,带阈值检测
- 进度指示器:毛玻璃效果的胶囊状指示器,带填充动画
- 交互时暂停:手动导航会暂时暂停自动切换
State Management
状态管理
tsx
const [currentIndex, setCurrentIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const [progressKey, setProgressKey] = useState(0); // Forces animation restart
const [isPaused, setIsPaused] = useState(false);
const [touchStart, setTouchStart] = useState<number | null>(null);tsx
const [currentIndex, setCurrentIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const [progressKey, setProgressKey] = useState(0); // Forces animation restart
const [isPaused, setIsPaused] = useState(false);
const [touchStart, setTouchStart] = useState<number | null>(null);Core Implementation
核心实现
tsx
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
const images = [
"/images/image-1.jpeg",
"/images/image-2.jpeg",
"/images/image-3.jpeg",
];
function ImageCarousel() {
const [currentIndex, setCurrentIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const [progressKey, setProgressKey] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const [touchStart, setTouchStart] = useState<number | null>(null);
// Auto-advance effect - only when hovered and not paused
useEffect(() => {
if (!isHovered || isPaused) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % images.length);
setProgressKey((prev) => prev + 1);
}, 3000);
return () => clearInterval(interval);
}, [isHovered, isPaused]);
// Touch handlers
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStart(e.touches[0].clientX);
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStart === null) return;
const touchEnd = e.changedTouches[0].clientX;
const diff = touchStart - touchEnd;
const threshold = 50;
if (Math.abs(diff) > threshold) {
if (diff > 0) {
// Swipe left - next image
setCurrentIndex((prev) => (prev + 1) % images.length);
} else {
// Swipe right - previous image
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
}
setProgressKey((prev) => prev + 1);
setIsPaused(true);
setTimeout(() => setIsPaused(false), 3000);
}
setTouchStart(null);
};
return (
<div
className="relative h-full w-full group touch-pan-y"
onMouseEnter={() => {
setIsHovered(true);
setProgressKey((prev) => prev + 1);
}}
onMouseLeave={() => setIsHovered(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Images with fade transition */}
{images.map((src, index) => (
<Image
key={src}
src={src}
alt={`Image ${index + 1}`}
fill
className={`object-cover transition-opacity duration-700 ease-in-out ${
index === currentIndex ? "opacity-100" : "opacity-0"
}`}
/>
))}
{/* Glassmorphic indicator container */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2">
<div className="flex items-center gap-2 px-3 py-2 rounded-full bg-black/20 backdrop-blur-md border border-white/10">
{images.map((_, index) => (
<button
key={index}
onClick={() => {
setCurrentIndex(index);
setIsPaused(true);
setProgressKey((prev) => prev + 1);
setTimeout(() => setIsPaused(false), 3000);
}}
className="relative cursor-pointer"
>
{/* Background pill */}
<div
className={`h-2 rounded-full transition-all duration-300 ${
index === currentIndex
? "w-6 bg-white"
: "w-2 bg-white/40 hover:bg-white/60"
}`}
/>
{/* Animated progress fill - only when hovered and not paused */}
{index === currentIndex && isHovered && !isPaused && (
<div
key={progressKey}
className="absolute inset-0 h-2 rounded-full bg-white/50 origin-left animate-carousel-progress"
style={{ animationDuration: "3000ms" }}
/>
)}
</button>
))}
</div>
</div>
</div>
);
}tsx
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
const images = [
"/images/image-1.jpeg",
"/images/image-2.jpeg",
"/images/image-3.jpeg",
];
function ImageCarousel() {
const [currentIndex, setCurrentIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const [progressKey, setProgressKey] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const [touchStart, setTouchStart] = useState<number | null>(null);
// Auto-advance effect - only when hovered and not paused
useEffect(() => {
if (!isHovered || isPaused) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % images.length);
setProgressKey((prev) => prev + 1);
}, 3000);
return () => clearInterval(interval);
}, [isHovered, isPaused]);
// Touch handlers
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStart(e.touches[0].clientX);
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStart === null) return;
const touchEnd = e.changedTouches[0].clientX;
const diff = touchStart - touchEnd;
const threshold = 50;
if (Math.abs(diff) > threshold) {
if (diff > 0) {
// Swipe left - next image
setCurrentIndex((prev) => (prev + 1) % images.length);
} else {
// Swipe right - previous image
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
}
setProgressKey((prev) => prev + 1);
setIsPaused(true);
setTimeout(() => setIsPaused(false), 3000);
}
setTouchStart(null);
};
return (
<div
className="relative h-full w-full group touch-pan-y"
onMouseEnter={() => {
setIsHovered(true);
setProgressKey((prev) => prev + 1);
}}
onMouseLeave={() => setIsHovered(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Images with fade transition */}
{images.map((src, index) => (
<Image
key={src}
src={src}
alt={`Image ${index + 1}`}
fill
className={`object-cover transition-opacity duration-700 ease-in-out ${
index === currentIndex ? "opacity-100" : "opacity-0"
}`}
/>
))}
{/* Glassmorphic indicator container */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2">
<div className="flex items-center gap-2 px-3 py-2 rounded-full bg-black/20 backdrop-blur-md border border-white/10">
{images.map((_, index) => (
<button
key={index}
onClick={() => {
setCurrentIndex(index);
setIsPaused(true);
setProgressKey((prev) => prev + 1);
setTimeout(() => setIsPaused(false), 3000);
}}
className="relative cursor-pointer"
>
{/* Background pill */}
<div
className={`h-2 rounded-full transition-all duration-300 ${
index === currentIndex
? "w-6 bg-white"
: "w-2 bg-white/40 hover:bg-white/60"
}`}
/>
{/* Animated progress fill - only when hovered and not paused */}
{index === currentIndex && isHovered && !isPaused && (
<div
key={progressKey}
className="absolute inset-0 h-2 rounded-full bg-white/50 origin-left animate-carousel-progress"
style={{ animationDuration: "3000ms" }}
/>
)}
</button>
))}
</div>
</div>
</div>
);
}Required CSS (add to globals.css)
所需CSS(添加至globals.css)
css
/* Carousel progress animation */
@keyframes carousel-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.animate-carousel-progress {
animation: carousel-progress linear forwards;
}css
/* Carousel progress animation */
@keyframes carousel-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.animate-carousel-progress {
animation: carousel-progress linear forwards;
}Key Behaviors
关键行为
Auto-Advance Logic
自动切换逻辑
| State | Behavior |
|---|---|
| Not hovered | No auto-advance |
| Hovered + not paused | Auto-advance every 3s |
| Hovered + paused | No auto-advance (resumes after 3s) |
| 状态 | 行为 |
|---|---|
| 未悬停 | 不自动切换 |
| 已悬停且未暂停 | 每3秒自动切换一次 |
| 已悬停且已暂停 | 不自动切换(3秒后恢复) |
Touch Swipe
触摸滑动
- Threshold: 50px minimum swipe distance
- Left swipe: Next image
- Right swipe: Previous image
- After swipe: Pause auto-advance for 3s
- 阈值:最小滑动距离50px
- 左滑:切换至下一张图片
- 右滑:切换至上一张图片
- 滑动后:自动切换暂停3秒
Progress Indicator
进度指示器
- Expands from dot (w-2) to pill (w-6) when active
- Shows animated fill overlay only when hovering and not paused
- forces animation restart on index change
progressKey
- 激活时从圆点(w-2)扩展为胶囊状(w-6)
- 仅在悬停且未暂停时显示填充动画
- 用于在索引变化时强制重启动画
progressKey
Indicator Sizing
指示器尺寸
| Context | Active Width | Inactive Width | Height |
|---|---|---|---|
| Preview (compact) | | | |
| Detail page | | | |
| 场景 | 激活状态宽度 | 未激活状态宽度 | 高度 |
|---|---|---|---|
| 预览(紧凑) | | | |
| 详情页 | | | |
Timing Configuration
时间配置
| Duration | Use |
|---|---|
| Auto-advance interval |
| Pause duration after manual interaction |
| Image fade transition |
| Indicator pill expansion |
| 时长 | 用途 |
|---|---|
| 自动切换间隔 |
| 手动交互后的暂停时长 |
| 图片淡入淡出过渡时长 |
| 指示器胶囊扩展时长 |
Checklist
检查清单
- on container for proper scroll behavior
touch-pan-y - Images use prop with
fillobject-cover - state for animation restart
progressKey - Pause timeout clears and resumes correctly
- keyframes added to globals.css
animate-carousel-progress
- 容器添加以保证正确滚动行为
touch-pan-y - 图片使用属性搭配
fillobject-cover - 配置状态用于重启动画
progressKey - 暂停超时能正确清除并恢复
- 关键帧已添加至globals.css
animate-carousel-progress