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

如何用Framer Motion实现含内部滚动组件的React页面滚动进度条?

问题描述

我正在用Framer Motion构建一个滚动进度条,需要追踪包含多组件的React页面的整体滚动进度。页面中有部分组件设置了min-h-screen且开启了overflow-y: scroll,希望进度条能把这些内部组件的滚动也计入整体进度(假设用户会先滚完内部组件再进入下一个区块)。但当前进度条加载后直接显示100%,完全不随滚动变化。

当前页面代码如下:

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { motion, useScroll, useTransform } from 'framer-motion';

import Section1 from './Section1';
import Section2 from './Section2';
import Section3 from './Section3';
import Section4 from './Section4';
import Section5 from './Section5';
import Section6 from './Section6';
import Section7 from './Section7';
import Section8 from './Section8';
import Section9 from './Section9';
import Section10 from './Section10';
import Section11 from './Section11'; 
import Section12 from './Section12';
import Section13 from './Section13';
import Section14 from './Section14';
import Section15 from './Section15'

import Qanda from './Qanda';
import Tldr from './Tldr';
import Landscape from './Landscape';

import Navigation from '../../_components/Navigation';
import { usePageTitle } from '../../_hooks/usePageTitle';


const ScrollProgressBar = () => {
  const { scrollYProgress } = useScroll(); // Track scroll progress
  const scaleX = useTransform(scrollYProgress, [0, 1], [0, 1]); // Transform scroll progress to scale

  return (
    <motion.div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        height: '4px',
        width: '100%',
        backgroundColor: 'red',
        scaleX, // Apply the scale based on scroll progress
        transformOrigin: '0%', // Scale from the left
        zIndex: 1000, // Ensure it's above other content
      }}
    />
  );
};

export default function CasePage() {
    const title = usePageTitle('DONOGHUE v STEVENSON');
    const [activeSection, setActiveSection] = useState(1);
    const sectionsRef = useRef<Array<HTMLDivElement | null>>([]);

    const scrollableRef = useRef(null);

    useEffect(() => {
      const handleScroll = () => {
        const scrollPosition = window.scrollY + window.innerHeight / 2;
  
        for (let i = sectionsRef.current.length - 1; i >= 0; i--) {
          const section = sectionsRef.current[i];
          if (section && section.offsetTop <= scrollPosition) {
            setActiveSection(i + 1);
            break;
          }
        }
      };
  
      window.addEventListener('scroll', handleScroll);
      return () => window.removeEventListener('scroll', handleScroll);
    }, []);
  
    const scrollToSection = (sectionId: string) => {
      const element = document.getElementById(sectionId);
      if (element) {
        element.scrollIntoView({ behavior: 'smooth' });
      }
    };

  
    return (
        <div className="relative w-full bg-parchment">
            <ScrollProgressBar />

            <Navigation title={title} scrollToSection={scrollToSection} />
        
            <div ref={el => { sectionsRef.current[0] = el; }} id="section1" className="section min-h-screen">
              <Section1 scrollToSection={scrollToSection} />
            </div>
          
          <div ref={el => { sectionsRef.current[1] = el; }} id="section2" className="section min-h-screen">
            <Section2 />
          </div>

          <div ref={el => { sectionsRef.current[2] = el; }} id="tldr" className="section">
            <Tldr />
          </div>

          <div ref={el => { sectionsRef.current[3] = el; }} id="landscape" className="section">
            <Landscape />
          </div>

          <div ref={el => { sectionsRef.current[4] = el; }} id="section3" className="section">
            <Section3 />
          </div>


          <div ref={el => { sectionsRef.current[5] = el; }} id="section4" className="section min-h-screen">
              <Section4 />
          </div>

          <div ref={el => { sectionsRef.current[6] = el; }} id="section5" className="section z-2">
            <Section5 />
          </div>

          <div ref={el => { sectionsRef.current[7] = el; }} id="section6" className="section min-h-screen">
            <Section6 />
          </div>
          <div ref={el => { sectionsRef.current[8] = el; }} id="section7" className="section min-h-screen">
            <Section7 />
          </div>

          <div ref={el => { sectionsRef.current[9] = el; }} id="section8" className="section min-h-screen">
            <Section8 />
          </div>

          <div ref={el => { sectionsRef.current[10] = el; }} id="section15" className="section min-h-screen z-2">
            <Section15 />
          </div>

          <div ref={el => { sectionsRef.current[11] = el; }} id="section9" className="section min-h-screen">
            <Section9 />
          </div>
          <div ref={el => { sectionsRef.current[12] = el; }} id="section10" className="section min-h-screen">
            <Section10 />
          </div>

          <div ref={el => { sectionsRef.current[13] = el; }} id="section11" className="section min-h-screen">
            <Section11 />
          </div>

          <div ref={el => { sectionsRef.current[14] = el; }} id="section12" className="section min-h-screen">
            <Section12 />
          </div>

          <div ref={el => { sectionsRef.current[15] = el; }} id="section13" className="section min-h-screen">
            <Section13 />
          </div>

          <div ref={el => { sectionsRef.current[16] = el; }} id="qanda" className="section min-h-screen">
            <Qanda />
          </div>
          <div ref={el => { sectionsRef.current[17] = el; }} id="section14" className="section min-h-screen">
            <Section14 />
          </div>
        </div>
  );
}

能否实现这样的进度条:同时追踪页面整体滚动和内部滚动组件的进度,比如用户滚动到Section4时,进度条先追踪该组件的内部滚动,直到组件滚到底部,再继续追踪页面的整体滚动?


解决方案

问题根源

默认的useScroll()只监听window的滚动事件,但内部开启overflow-y: scroll的组件滚动时,window的scrollY不会变化,导致进度条无法感知这部分滚动。另外,当前代码没有计算内部滚动组件的可滚动长度,所以进度条会直接显示100%。

实现思路

要实现整体进度追踪,需要:

  1. 计算总滚动长度:页面可滚动长度 + 所有内部可滚动组件的可滚动长度(scrollHeight - clientHeight)
  2. 监听window和所有内部可滚动组件的滚动事件
  3. 实时计算当前总滚动进度:页面滚动距离 + 已完成的内部组件滚动长度 + 当前内部组件的滚动距离,再除以总滚动长度
  4. 将计算好的进度传递给进度条组件,用Framer Motion实现动画

修改后的代码实现

1. 重构ScrollProgressBar为受控组件

const ScrollProgressBar = ({ progress }: { progress: number }) => {
  // 用useTransform实现平滑过渡动画
  const scaleX = useTransform(progress, [0, 1], [0, 1]);

  return (
    <motion.div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        height: '4px',
        width: '100%',
        backgroundColor: 'red',
        scaleX,
        transformOrigin: '0%',
        zIndex: 1000,
      }}
    />
  );
};

2. 修改CasePage组件,添加滚动监听和进度计算

export default function CasePage() {
  const title = usePageTitle('DONOGHUE v STEVENSON');
  const [activeSection, setActiveSection] = useState(1);
  const [totalProgress, setTotalProgress] = useState(0);
  const sectionsRef = useRef<Array<HTMLDivElement | null>>([]);
  // 内部可滚动组件的ref(这里以Section4为例)
  const section4Ref = useRef<HTMLDivElement>(null);

  // 计算总滚动长度
  const calculateTotalScrollLength = () => {
    const pageScrollLength = document.body.scrollHeight - window.innerHeight;
    // Section4的可滚动长度
    const section4ScrollLength = section4Ref.current 
      ? section4Ref.current.scrollHeight - section4Ref.current.clientHeight 
      : 0;
    // 如果有其他内部滚动组件,在这里累加
    return pageScrollLength + section4ScrollLength;
  };

  // 计算当前总进度
  const calculateCurrentProgress = () => {
    const totalLength = calculateTotalScrollLength();
    if (totalLength === 0) return 0;

    const section4 = section4Ref.current;
    if (!section4) return window.scrollY / totalLength;

    // Section4的位置信息
    const section4OffsetTop = section4.offsetTop;
    const section4ScrollLength = section4.scrollHeight - section4.clientHeight;
    const windowScrollY = window.scrollY;
    const section4ScrollTop = section4.scrollTop;

    let currentProgress = 0;

    // 情况1:还没滚动到Section4
    if (windowScrollY + window.innerHeight < section4OffsetTop) {
      currentProgress = windowScrollY / totalLength;
    } 
    // 情况2:正在滚动Section4内部
    else if (section4ScrollTop < section4ScrollLength) {
      // 页面滚动到Section4顶部的距离
      const scrollToSection4Top = Math.max(0, section4OffsetTop - window.innerHeight);
      // 总进度 = 页面滚动到Section4的进度 + Section4内部滚动的进度
      currentProgress = (scrollToSection4Top + section4ScrollTop) / totalLength;
    } 
    // 情况3:Section4已滚完,继续滚动页面
    else {
      // 总进度 = 页面滚动到Section4的进度 + Section4内部全滚完的进度 + 后续页面滚动的进度
      const scrollToSection4Top = Math.max(0, section4OffsetTop - window.innerHeight);
      const postSection4Scroll = windowScrollY - scrollToSection4Top;
      currentProgress = (scrollToSection4Top + section4ScrollLength + postSection4Scroll) / totalLength;
    }

    // 进度限制在0-1之间
    return Math.min(Math.max(currentProgress, 0), 1);
  };

  // 监听滚动和窗口 resize
  useEffect(() => {
    const updateProgress = () => {
      setTotalProgress(calculateCurrentProgress());
    };

    // 监听window滚动
    window.addEventListener('scroll', updateProgress);
    // 监听Section4内部滚动
    section4Ref.current?.addEventListener('scroll', updateProgress);
    // 窗口resize时重新计算
    window.addEventListener('resize', updateProgress);

    // 初始化进度
    updateProgress();

    return () => {
      window.removeEventListener('scroll', updateProgress);
      section4Ref.current?.removeEventListener('scroll', updateProgress);
      window.removeEventListener('resize', updateProgress);
    };
  }, []);

  // 原有的activeSection监听逻辑
  useEffect(() => {
    const handleScroll = () => {
      const scrollPosition = window.scrollY + window.innerHeight / 2;
      for (let i = sectionsRef.current.length - 1; i >= 0; i--) {
        const section = sectionsRef.current[i];
        if (section && section.offsetTop <= scrollPosition) {
          setActiveSection(i + 1);
          break;
        }
      }
    };
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  const scrollToSection = (sectionId: string) => {
    const element = document.getElementById(sectionId);
    if (element) {
      element.scrollIntoView({ behavior: 'smooth' });
    }
  };

  return (
    <div className="relative w-full bg-parchment">
      <ScrollProgressBar progress={totalProgress} />
      <Navigation title={title} scrollToSection={scrollToSection} />
      
      {/* 其他section保持不变,修改Section4的ref */}
      <div ref={el => { sectionsRef.current[5] = el; }} id="section4" className="section min-h-screen">
        {/* 假设Section4内部有可滚动容器,把ref挂到这个容器上 */}
        <div ref={section4Ref} style={{ height: '100%', overflowY: 'scroll' }}>
          <Section4 />
        </div>
      </div>

      {/* 其余section代码保持不变 */}
    </div>
  );
}

注意事项

  • 如果有多个内部滚动组件,需要给每个组件添加ref,在calculateTotalScrollLength和calculateCurrentProgress中逐一处理它们的滚动状态
  • 确保内部滚动组件的容器设置了height: 100%和overflow-y: scroll,这样scrollHeight和clientHeight的计算才准确
  • 进度计算中加入了边界判断,避免进度超出0-1的范围

内容的提问来源于stack exchange,提问作者Mel

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 23:47:02