You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React实现图片数组无限滚动:解决滑块切换跳转首尾的问题

实现平滑无限滚动的滑块(无第三方依赖)

当前代码在滑块滚动到边界时直接切换索引,导致出现跳转感。要实现无缝的无限滚动,核心思路是复制首尾滑块并在滚动完成后无缝重置索引,模拟无限循环的视觉效果,具体修改如下:

步骤1:修改滑块数据,添加首尾复制项

在组件内部,给原滑块数组的前后各添加一个复制项:

const extendedSliderItems = [
  ...sliderItems.slice(-1), // 复制最后一个真实滑块
  ...sliderItems,           // 所有真实滑块
  ...sliderItems.slice(0,1) // 复制第一个真实滑块
];

步骤2:调整初始索引与点击逻辑

初始索引设为1(因为第一个是复制的最后一个滑块,默认显示第一个真实滑块);点击箭头时直接增减索引,无需判断边界:

const [slideIndex, setSlideIndex] = useState(1);

const handleClick = (direction) => {
  if(direction === "left"){
    setSlideIndex(prev => prev - 1);
  }else{
    setSlideIndex(prev => prev + 1);
  }
};

步骤3:监听滚动结束事件,无缝重置索引

使用useRef获取Wrapper元素,监听transitionend事件,当滚动到复制的滑块时,瞬间重置索引并临时关闭过渡动画,避免跳转感:

import React, { useState, useRef, useEffect } from 'react' // 新增useRef和useEffect
// ... 其他导入不变

const Slider = () => {
  const [slideIndex, setSlideIndex] = useState(1);
  const wrapperRef = useRef(null);

  useEffect(() => {
    const handleTransitionEnd = () => {
      // 滚动到开头的复制滑块时,重置到真实的最后一个滑块索引
      if(slideIndex === 0){
        setSlideIndex(sliderItems.length);
        wrapperRef.current.style.transition = 'none';
        // 强制重绘后恢复过渡动画
        requestAnimationFrame(() => {
          wrapperRef.current.style.transition = 'transform 1.5s ease-in-out';
        });
      }
      // 滚动到结尾的复制滑块时,重置到真实的第一个滑块索引
      if(slideIndex === sliderItems.length + 1){
        setSlideIndex(1);
        wrapperRef.current.style.transition = 'none';
        requestAnimationFrame(() => {
          wrapperRef.current.style.transition = 'transform 1.5s ease-in-out';
        });
      }
    };

    const wrapper = wrapperRef.current;
    wrapper?.addEventListener('transitionend', handleTransitionEnd);
    return () => wrapper?.removeEventListener('transitionend', handleTransitionEnd);
  }, [slideIndex, sliderItems.length]);

  // ... handleClick逻辑不变

步骤4:更新JSX渲染部分

修改Wrapper的ref绑定,同时渲染扩展后的滑块数组(注意重复项的key要添加索引区分):

<Wrapper ref={wrapperRef} slideIndex={slideIndex}>
  {extendedSliderItems.map((item, idx) => (
    <Slide key={`${item.id}-${idx}`}>
      <ImgContainer>
        <Image src={item.img} alt={item.alt}/>
      </ImgContainer>
      <InfoContainer>
        <Title>{item.title}</Title>
        <Desc>{item.desc}</Desc>
        <Button>Shop Now</Button>
      </InfoContainer>
    </Slide>
  ))}
</Wrapper>

完整修改后的组件代码

import React, { useState, useRef, useEffect } from 'react'
import styled from 'styled-components';
import ArrowLeftIcon from '@mui/icons-material/ArrowLeft';
import ArrowRightIcon from '@mui/icons-material/ArrowRight';
import { sliderItems } from '../data';
import { mobile } from '../responsive';

const Container = styled.div`
    width: 100%;
    height: 100vh;
    display: flex;
    position: relative;
    overflow: hidden;
`;

const Arrow = styled.div`
    width: 50px;
    height: 50px;
    background-color: #eee;
    border-radius: 50%;
    display: grid;
    place-items: center;
    position: absolute;
    top: 0;
    bottom: 0;
    left: ${props => props.direction === "left" && "10px"};
    right: ${props => props.direction === "right" && "10px"};
    margin: auto;
    cursor: pointer;
    opacity: 0.5;
    z-index: 2;
    ${mobile({width:"30px", height:"30px"})}
`;

const Wrapper = styled.div`
    height: 100%;
    display: flex;
    transform: translateX(${props =>props.slideIndex * -100}vw);
    transition: transform 1.5s ease-in-out;
`;

const Slide = styled.div`
    width: 100vw;
    height: 100%;
    display: flex;
    align-items: center;
`;

const ImgContainer = styled.div`
    height: 100%;
    flex: 1;
`;

const Image = styled.img`
    width: 100%;
    height: 100%;
    object-fit: cover;
`;

const InfoContainer = styled.div`
    margin-left: 50px;
    flex: 1;
    padding: 10px;
    ${mobile({
        display: "none"
    })}
`;

const Title = styled.h1`
    font-size: 60px;
`;

const Desc = styled.p`
    margin: 50px 0px;
    font-size: 20px;
    font-weight: 500;
`;

const Button = styled.button`
    padding: 10px;
    font-size: 25px;
    background-color: transparent;
    cursor: pointer;
    border-radius: 4px;
    transition: all 0.25s ease;
    &:hover {
        transform: scale(1.05);
        color: white;
        background-color: black;
    }
`;

const Slider = () => {

    const [slideIndex, setSlideIndex] = useState(1);
    const wrapperRef = useRef(null);

    // 扩展滑块数组,添加首尾复制项
    const extendedSliderItems = [
        ...sliderItems.slice(-1),
        ...sliderItems,
        ...sliderItems.slice(0,1)
    ];

    const handleClick = (direction) => {
        if(direction === "left"){
            setSlideIndex(prev => prev - 1);
        }else{
            setSlideIndex(prev => prev + 1);
        }
    };

    useEffect(() => {
        const handleTransitionEnd = () => {
            if(slideIndex === 0){
                setSlideIndex(sliderItems.length);
                wrapperRef.current.style.transition = 'none';
                requestAnimationFrame(() => {
                    wrapperRef.current.style.transition = 'transform 1.5s ease-in-out';
                });
            }
            if(slideIndex === sliderItems.length + 1){
                setSlideIndex(1);
                wrapperRef.current.style.transition = 'none';
                requestAnimationFrame(() => {
                    wrapperRef.current.style.transition = 'transform 1.5s ease-in-out';
                });
            }
        };

        const wrapper = wrapperRef.current;
        wrapper?.addEventListener('transitionend', handleTransitionEnd);
        return () => wrapper?.removeEventListener('transitionend', handleTransitionEnd);
    }, [slideIndex, sliderItems.length]);


    return (
        <Container>
            <Arrow direction="left" onClick={() => handleClick("left")}>
                <ArrowLeftIcon />
            </Arrow>
            <Wrapper ref={wrapperRef} slideIndex={slideIndex}>
                {extendedSliderItems.map((item, idx) => (
                    <Slide key={`${item.id}-${idx}`}>
                        <ImgContainer>
                            <Image src={item.img} alt={item.alt}/>
                        </ImgContainer>
                        <InfoContainer>
                            <Title>{item.title}</Title>
                            <Desc>{item.desc}</Desc>
                            <Button>Shop Now</Button>
                        </InfoContainer>
                    </Slide>
                ))}
            </Wrapper>
            <Arrow direction="right" onClick={() => handleClick("right")}>
                <ArrowRightIcon />
            </Arrow>
        </Container>
    )
}

export default Slider;

原理说明

  • 通过在真实滑块的前后各复制一个滑块,让滚动到边界时看起来是继续滚动到下一个/上一个滑块;
  • 在滚动动画结束后,瞬间将索引重置到对应的真实滑块位置,并临时关闭过渡动画,用户视觉上感知不到跳转,从而实现无缝的无限滚动效果。

内容的提问来源于stack exchange,提问作者harjit.kainth7

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.23 14:03:23