跳到主要内容
Example+

滚动控制

ui

01进度条在补什么

滚动条常常被样式藏起来,读者失去了「还剩多少」的参照。进度条把不可见的滚动位置翻译成一条看得懂的线,让人知道自己站在哪儿。

02useScroll 的两种来源

不传参数时它读整个页面的滚动;传入 container 之后,进度只属于这个可滚动元素,外面的页面滚不滚都与它无关。

03scrollYProgress 是什么

一个从 0 到 1 的 MotionValue。0 是还没开始,1 是已经到底。它在动画帧里更新,不会触发 React 重渲染,所以滚得再快也不掉帧。

04为什么要套一层 spring

原始进度和滚轮是一比一的,手一停它就停。用 useSpring 包一层,进度条会带着惯性追上来,顺带把触控板的细碎抖动也抹平了。

05用 scaleX 而不是改宽度

缩放走的是 GPU 合成层,不触发布局回流;宽度不行。再配上 origin-left,条子才是从左边长出来,而不是从中间往两头撑。

06stiffness 与 damping

stiffness 决定追得多急,damping 决定停得多稳。200 / 40 是一组克制的取值:跟手,但停下时不会来回晃。

07它是氛围,不是导航

进度条只回答「还有多远」,回答不了「我要去哪儿」。需要跳转的长文,该给目录还是得给目录。

08最容易踩的坑

container 指向的元素必须自己能滚:既要 overflow-y-auto,也要有确定的高度。少给高度,scrollYProgress 会一直停在 0。

src

TSX
// npm install motion
"use client";

import { useRef } from "react";
import { motion, useScroll, useSpring } from "motion/react";

const chapters = [
    {
        title: "进度条在补什么",
        body: "滚动条常常被样式藏起来,读者失去了「还剩多少」的参照。进度条把不可见的滚动位置翻译成一条看得懂的线,让人知道自己站在哪儿。",
    },
    {
        title: "useScroll 的两种来源",
        body: "不传参数时它读整个页面的滚动;传入 container 之后,进度只属于这个可滚动元素,外面的页面滚不滚都与它无关。",
    },
    {
        title: "scrollYProgress 是什么",
        body: "一个从 0 到 1 的 MotionValue。0 是还没开始,1 是已经到底。它在动画帧里更新,不会触发 React 重渲染,所以滚得再快也不掉帧。",
    },
    {
        title: "为什么要套一层 spring",
        body: "原始进度和滚轮是一比一的,手一停它就停。用 useSpring 包一层,进度条会带着惯性追上来,顺带把触控板的细碎抖动也抹平了。",
    },
    {
        title: "用 scaleX 而不是改宽度",
        body: "缩放走的是 GPU 合成层,不触发布局回流;宽度不行。再配上 origin-left,条子才是从左边长出来,而不是从中间往两头撑。",
    },
    {
        title: "stiffness 与 damping",
        body: "stiffness 决定追得多急,damping 决定停得多稳。200 / 40 是一组克制的取值:跟手,但停下时不会来回晃。",
    },
    {
        title: "它是氛围,不是导航",
        body: "进度条只回答「还有多远」,回答不了「我要去哪儿」。需要跳转的长文,该给目录还是得给目录。",
    },
    {
        title: "最容易踩的坑",
        body: "container 指向的元素必须自己能滚:既要 overflow-y-auto,也要有确定的高度。少给高度,scrollYProgress 会一直停在 0。",
    },
];

export default function Page() {
    const ref = useRef<HTMLDivElement>(null);
    const { scrollYProgress } = useScroll({ container: ref });
    const scaleX = useSpring(scrollYProgress, { stiffness: 200, damping: 40 });

    return (
        <div className="mx-auto max-w-4xl space-y-5 px-10 py-20">
            <motion.div
                style={{ scaleX }}
                className="h-2 origin-left bg-gradient-to-r from-purple-700 to-teal-400"
            />

            <div ref={ref} className="h-[480px] overflow-y-auto pr-4">
                {chapters.map((c, i) => (
                    <section key={c.title} className="mb-10 space-y-3">
                        <h3 className="text-2xl font-bold text-white/90">
                            <span className="mr-2 font-mono text-white">
                                {String(i + 1).padStart(2, "0")}
                            </span>
                            {c.title}
                        </h3>
                        <p className="leading-relaxed text-white/60">{c.body}</p>
                    </section>
                ))}
            </div>
        </div>
    );
}