image-carousel

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Image 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

自动切换逻辑

StateBehavior
Not hoveredNo auto-advance
Hovered + not pausedAuto-advance every 3s
Hovered + pausedNo 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
  • progressKey
    forces animation restart on index change
  • 激活时从圆点(w-2)扩展为胶囊状(w-6)
  • 仅在悬停且未暂停时显示填充动画
  • progressKey
    用于在索引变化时强制重启动画

Indicator Sizing

指示器尺寸

ContextActive WidthInactive WidthHeight
Preview (compact)
w-4
w-1.5
h-1.5
Detail page
w-6
w-2
h-2
场景激活状态宽度未激活状态宽度高度
预览(紧凑)
w-4
w-1.5
h-1.5
详情页
w-6
w-2
h-2

Timing Configuration

时间配置

DurationUse
3000ms
Auto-advance interval
3000ms
Pause duration after manual interaction
700ms
Image fade transition
300ms
Indicator pill expansion
时长用途
3000ms
自动切换间隔
3000ms
手动交互后的暂停时长
700ms
图片淡入淡出过渡时长
300ms
指示器胶囊扩展时长

Checklist

检查清单

  • touch-pan-y
    on container for proper scroll behavior
  • Images use
    fill
    prop with
    object-cover
  • progressKey
    state for animation restart
  • Pause timeout clears and resumes correctly
  • animate-carousel-progress
    keyframes added to globals.css
  • 容器添加
    touch-pan-y
    以保证正确滚动行为
  • 图片使用
    fill
    属性搭配
    object-cover
  • 配置
    progressKey
    状态用于重启动画
  • 暂停超时能正确清除并恢复
  • animate-carousel-progress
    关键帧已添加至globals.css