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

求助:React实现垂直滚动时的水平堆叠切换动画效果

如何实现垂直滚动触发的水平堆叠切换动画(React)

问题描述

我尝试实现垂直滚动时的水平堆叠切换效果,但一直没成功。目前通过监听滚动位置切换图片和文本内容,但React里加不上过渡动画,不确定是不是实现方式错了。附上当前代码:

原React组件代码

function Section() {
  const colors = [
    <div className={styles.card}>
      <img src={img1} alt="" />
    </div>,
    <div className={styles.card}>
      <img src={img2} alt="" />
    </div>,
    <div className={styles.card}>
      <img src={img3} alt="" />
    </div>,
    <div className={styles.card}>
      <img src={img4} alt="" />
    </div>,
  ];
  const headings = [
    "X",
    "Y",
    "Z",
    "W",
  ];

  const contents = [
    "X",
    "Y",
    "Z",
    "W",
  ];
  const [scrolling, setScrolling] = useState(colors[0]);
  const [heading, setHeading] = useState(headings[0]);
  const [content, setContent] = useState(contents[0]);
  const containerRef = useRef(null);

  const handleScroll = () => {
    if (!containerRef.current) return;

    const scrollTop = containerRef.current.getBoundingClientRect().top;
    console.log(scrollTop);
    if (scrollTop >= -500 && scrollTop < 0) {
      setScrolling(colors[0]);
      setHeading(headings[0]);
      setContent(contents[0]);
    } else if (scrollTop >= -1000 && scrollTop < -500) {
      setScrolling(colors[1]);
      setHeading(headings[1]);
      setContent(contents[1]);
    } else if (scrollTop >= -1500 && scrollTop < -1000) {
      setScrolling(colors[2]);
      setHeading(headings[2]);
      setContent(contents[2]);
    } else if (scrollTop >= -2000 && scrollTop < -1500) {
      setScrolling(colors[3]);
      setHeading(headings[3]);
      setContent(contents[3]);
    }
  };

  useEffect(() => {
    const parentElement = containerRef.current;

    parentElement.addEventListener("scroll", handleScroll);

    return () => {
      parentElement.removeEventListener("scroll", handleScroll);
    };
  }, []);

  useEffect(() => {
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, []);

  return (
    <div className={styles.cardContainer} ref={containerRef}>
      <div className={styles.wrapper}>
        <section className={styles.cardParent}>
          {scrolling}
          {window.innerWidth > 1100 && (
            <React.Fragment>
              <div className={`${styles.card} ${styles.dummy1}`}>
                <img src={bg} alt="" />
              </div>
              <div className={`${styles.card} ${styles.dummy2}`}>
                <img src={bg} alt="" />
              </div>
            </React.Fragment>
          )}
        </section>

        <aside className={styles.sideHeadings}>
          <section className={styles.sideHeadingsContent}>
            <h3>{heading}</h3>
            <p>{content}</p>
            <Link to={"/signup"} className="btn">
              Let's Start
            </Link>
          </section>
        </aside>
      </div>
    </div>
  );
}

原CSS代码

.cardContainer {
  display: flex;
  justify-content: space-around; 
  position: relative;
  margin-top: 10vh;
  height: 2300px;
  width: 90vw;
  overflow: visible;
}

.wrapper {
  display: flex;
  gap: 3rem;
  justify-content: space-between;
  align-items: center;
  position: sticky;
  top: 10vh;

  height: 70vh;
  width: 100%;
}
.cardParent {
  height: 100%;
  width: 90%;
}

.card {
  height: 100%;
  width: 100%;
  z-index: 999;
  
}

.card > img {
  margin-right: auto;
  width: fit-content;
  height: 100% ;
  object-fit: contain;
}

.dummy1{
  height: 100%;
  width: fit-content;
  position: absolute;
  top: 0;
  z-index: -9;
  display: flex;
}

.dummy1 > img{
  height: 90%;
  width: 100%;
  translate: 15% 5% 0;
  object-fit: contain;
  border-radius: 3rem !important;
  border-right: 1px solid rgb(149,149,149);
}

.dummy2 {
  height: 100%;
  width: fit-content;
  position: absolute;
  top: 0;
  z-index: -90;
  display: flex;  
}
.dummy2 > img{
  height: 80%;
  width: 100%;
  translate: 33% 12% 0;
  object-fit: contain;
  border-radius: 2rem !important;
  border-right: 1px solid rgb(149,149,149);
}

.sideHeadings {
  display: flex;
  align-items: center;
  height: 100%;
  width: 100%;
}

.sideHeadingsContent{
  display: flex;
  flex-direction: column;
  justify-content: space-between;

  height:60%;
  width:100%;
}

.sideHeadingsContent > h3 {
  font-size: var(--h2-large);
  font-family: var(--font-inter);
  font-weight: 400;
  color: rgb(201,201,201);
}

.sideHeadingsContent >p{
  font-family: var(--font-poppins);
  color: rgb(149,149,149);
  font-size: var(--para-large);
}

.sideHeadingsContent > a{
  height: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  font-weight: 500;
  background: linear-gradient(90deg,rgba(221,255,0,.5)60%,rgb(221,255,0) 100%);
  /* transition: all .6s ease !important; */
}
.sideHeadingsContent > a:hover{
  background: black !important;
}

/* Tablet */

@media  (width<= 1100px) {
  .cardContainer{
    margin-bottom: 30vh;
  }
  .wrapper {
    flex-direction: column;
  }

  .sideHeadings{
    width: 90vw;
  }

  .sideHeadingsContent{
    justify-content: center;
    align-items: center;
    text-align: center;
    gap: 2rem;
    width: 90vw;
  }
  .sideHeadingsContent > h3{
    font-size: var(--h3-large);
  }

  .wrapper img{
    width: 90vw;
    height: 40vh;
  }

  .sideHeadingsContent > a{
    margin: 0;
    font-size: 16px;
  }
}

@media (width<=768px) {
  .sideHeadingsContent > h1{
    font-size: var(--h1-heading-mobile);
  }

  .sideHeadingsContent > h3{
    font-size: var(--h3-heading-mobile);
  }

  .wrapper img{
    width: 90vw;
    height: 40vh;
  }
}

问题分析

  1. 直接替换JSX元素会导致React卸载旧元素、渲染新元素,无法触发CSS过渡——过渡需要元素状态变化而非替换
  2. 同时监听容器和window滚动会导致逻辑冲突,实际只需监听window滚动(容器是静态高度,靠window滚动触发位置变化)
  3. 未给切换元素添加过渡状态的CSS规则,没有动画触发条件

解决方案与修改后代码

1. 重构React组件逻辑

  • 用索引控制内容显示,保留所有元素在DOM中,通过CSS类切换状态
  • 基于滚动位置计算当前激活索引,实现平滑切换
  • 清理冗余的滚动监听

修改后的React组件代码

import { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import img1 from './path/to/img1.jpg';
import img2 from './path/to/img2.jpg';
import img3 from './path/to/img3.jpg';
import img4 from './path/to/img4.jpg';
import bg from './path/to/bg.jpg';
import styles from './Section.module.css';

function Section() {
  const items = [
    { img: img1, heading: "X", content: "X内容描述" },
    { img: img2, heading: "Y", content: "Y内容描述" },
    { img: img3, heading: "Z", content: "Z内容描述" },
    { img: img4, heading: "W", content: "W内容描述" },
  ];
  const [activeIndex, setActiveIndex] = useState(0);
  const containerRef = useRef(null);
  const itemHeight = 500; // 每个切换区间的高度

  const handleScroll = () => {
    if (!containerRef.current) return;
    const containerTop = containerRef.current.getBoundingClientRect().top;
    // 计算当前滚动到的区间索引
    const index = Math.floor(Math.abs(containerTop) / itemHeight);
    // 限制索引范围
    const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
    if (clampedIndex !== activeIndex) {
      setActiveIndex(clampedIndex);
    }
  };

  useEffect(() => {
    window.addEventListener("scroll", handleScroll, { passive: true });
    return () => window.removeEventListener("scroll", handleScroll);
  }, []);

  return (
    <div className={styles.cardContainer} ref={containerRef}>
      <div className={styles.wrapper}>
        <section className={styles.cardParent}>
          {/* 所有卡片保留在DOM中,通过active类控制显示 */}
          {items.map((item, index) => (
            <div 
              key={index}
              className={`${styles.card} ${index === activeIndex ? styles.active : ''}`}
            >
              <img src={item.img} alt={item.heading} />
            </div>
          ))}
          {window.innerWidth > 1100 && (
            <>
              <div className={`${styles.card} ${styles.dummy1}`}>
                <img src={bg} alt="背景占位" />
              </div>
              <div className={`${styles.card} ${styles.dummy2}`}>
                <img src={bg} alt="背景占位" />
              </div>
            </>
          )}
        </section>

        <aside className={styles.sideHeadings}>
          <section className={styles.sideHeadingsContent}>
            {/* 文本内容同样通过active类切换动画 */}
            {items.map((item, index) => (
              <div 
                key={index}
                className={`${styles.textContent} ${index === activeIndex ? styles.active : ''}`}
              >
                <h3>{item.heading}</h3>
                <p>{item.content}</p>
              </div>
            ))}
            <Link to={"/signup"} className="btn">
              Let's Start
            </Link>
          </section>
        </aside>
      </div>
    </div>
  );
}

export default Section;

2. 添加CSS过渡动画

  • 给卡片和文本元素添加transition属性
  • 用opacity和transform实现淡入淡出+位移效果
  • 控制元素层级确保激活内容在最上层

修改后的CSS代码

.cardContainer {
  display: flex;
  justify-content: space-around; 
  position: relative;
  margin-top: 10vh;
  height: 2300px;
  width: 90vw;
  overflow: visible;
}

.wrapper {
  display: flex;
  gap: 3rem;
  justify-content: space-between;
  align-items: center;
  position: sticky;
  top: 10vh;
  height: 70vh;
  width: 100%;
}

.cardParent {
  height: 100%;
  width: 90%;
  position: relative; /* 卡片绝对定位的容器 */
}

.card {
  position: absolute; /* 所有卡片重叠 */
  top: 0;
  left: 0;
  height: 100%;
  width: 100%;
  opacity: 0;
  transform: translateX(20px);
  transition: all 0.5s ease;
  z-index: 1;
}

.card.active {
  opacity: 1;
  transform: translateX(0);
  z-index: 999;
}

.card > img {
  margin-right: auto;
  width: fit-content;
  height: 100% ;
  object-fit: contain;
}

.dummy1, .dummy2 {
  opacity: 1;
  transform: none;
  transition: none; /* 占位卡片不需要动画 */
}

.dummy1{
  height: 100%;
  width: fit-content;
  position: absolute;
  top: 0;
  z-index: -9;
  display: flex;
}

.dummy1 > img{
  height: 90%;
  width: 100%;
  translate: 15% 5% 0;
  object-fit: contain;
  border-radius: 3rem !important;
  border-right: 1px solid rgb(149,149,149);
}

.dummy2 {
  height: 100%;
  width: fit-content;
  position: absolute;
  top: 0;
  z-index: -90;
  display: flex;  
}
.dummy2 > img{
  height: 80%;
  width: 100%;
  translate: 33% 12% 0;
  object-fit: contain;
  border-radius: 2rem !important;
  border-right: 1px solid rgb(149,149,149);
}

.sideHeadings {
  display: flex;
  align-items: center;
  height: 100%;
  width: 100%;
}

.sideHeadingsContent{
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  height:60%;
  width:100%;
  position: relative; /* 文本绝对定位的容器 */
}

.textContent {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  opacity: 0;
  transform: translateY(10px);
  transition: all 0.5s ease;
}

.textContent.active {
  opacity: 1;
  transform: translateY(0);
}

.sideHeadingsContent > h3 {
  font-size: var(--h2-large);
  font-family: var(--font-inter);
  font-weight: 400;
  color: rgb(201,201,201);
}

.sideHeadingsContent > p{
  font-family: var(--font-poppins);
  color: rgb(149,149,149);
  font-size: var(--para-large);
}

.sideHeadingsContent > a{
  height: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  font-weight: 500;
  background: linear-gradient(90deg,rgba(221,255,0,.5)60%,rgb(221,255,0) 100%);
  transition: all .6s ease !important;
  position: relative;
  z-index: 1
相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.15 11:01:53