`start: "top top"` 写错就毁了:两个 GSAP 规范骨架逐行读

2026-08-09

本文所有仓库信息核对日为 2026-08-09,以上游仓库最新内容为准。

skills/taste-skill/SKILL.md 的 §5 里,有两段完整可粘贴的 TSX 代码。它们不是伪代码,不是「参考思路」,是带 import、带清理函数、带 className 的成品骨架。而这两段代码被反复强调的关键字段只有一个:start: "top top"

这在这份 SKILL.md 里算是异类。文件里占篇幅最大的是「不要做什么」的禁令(§9 那一整套 AI Tells、§14 那份 62 个复选框的清单都是这个路子),而 §5 这两段是少数几处直接给出「照这个写」的正面样板。值得单独拆一次。

一、它修的是哪两个具体故障

原文把两个模式各自的「常见失败」写得很具体,这是判断你要不要照抄的前提。

GSAP Sticky-Stack Pattern:滚动卡片堆必须是真正的 sticky-stack,不是顺序揭示列表。常见失败是触发器在滚到一半才触发,而不是钉在视口顶部。修法原文写的是 start: "top top"不是 start: "top center"也不是 "top 80%"

GSAP Horizontal-Pan Pattern:常见失败是区块还没钉住、动画就开始了,用户看到半张幻灯片。修法是同一条:start: "top top",钉住外层,滚动驱动内层轨道。

两个不同的模式、两种不同的观感故障,被归到同一个字段的同一个取值上。至于 top centertop 80% 在 ScrollTrigger 里具体怎么解析、为什么会导致上面那两种观感,SKILL.md 没展开,我们也没有实跑验证过,这部分请以 GSAP 官方文档为准。

二、Sticky-Stack 骨架逐行读

"use client";
import { useRef, useEffect } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useReducedMotion } from "motion/react";

gsap.registerPlugin(ScrollTrigger);

export function StickyStack({ cards }: { cards: React.ReactNode[] }) {
  const ref = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    if (reduce || !ref.current) return;
    const ctx = gsap.context(() => {
      const cardEls = gsap.utils.toArray<HTMLElement>(".stack-card");
      cardEls.forEach((card, i) => {
        if (i === cardEls.length - 1) return;
        ScrollTrigger.create({
          trigger: card,
          start: "top top",                              // pin at viewport top
          endTrigger: cardEls[cardEls.length - 1],
          end: "top top",
          pin: true,
          pinSpacing: false,
        });
        gsap.to(card, {
          scale: 0.92,
          opacity: 0.55,
          ease: "none",
          scrollTrigger: {
            trigger: cardEls[i + 1],
            start: "top bottom",
            end: "top top",
            scrub: true,
          },
        });
      });
    }, ref);
    return () => ctx.revert();
  }, [reduce]);

  return (
    <div ref={ref} className="relative">
      {cards.map((card, i) => (
        <div
          key={i}
          className="stack-card sticky top-0 min-h-[100dvh] flex items-center justify-center"
        >
          {card}
        </div>
      ))}
    </div>
  );
}

几个位置值得停一下。

第一行是 "use client" 这不是装饰。这份 skill 在别处要求动效隔离在带 'use client' 的叶子组件里,骨架自己遵守了这一点。

useReducedMotion() 来自 motion/react,而不是 GSAP。 注意骨架里 motion/react 只被用来取这一个 hook,动画本身全部由 GSAP 跑。拿到 reduce 之后第一件事是 if (reduce || !ref.current) return; —— 直接不建动画,而不是建完再关掉。

gsap.context(...)return () => ctx.revert(); 这是这段代码里唯一的清理路径。骨架把它写在 useEffect 的返回值里,依赖数组是 [reduce]

if (i === cardEls.length - 1) return; 原文关键点写得很直白:除最后一张外每张卡都被钉住。最后一张不需要,因为它是 endTrigger

两个 ScrollTrigger 干的是两件事。 第一个 ScrollTrigger.create 负责钉住(pin: truepinSpacing: false),end 也是 "top top",终点绑在最后一张卡上。第二个是 gsap.to,负责 scale: 0.92opacity: 0.55 的视觉退场,ease: "none"scrub: true

这里最容易读漏的是:这个缩放动画的 trigger 是 cardEls[i + 1],不是当前这张卡。 原文点名了这一条 —— scale/opacity 变换由下一张卡的 scroll trigger 驱动,所以前一张在后一张到来时才缩小。如果你把 trigger 写回当前卡,动作时机就完全变了。

容器用 min-h-[100dvh],不是 h-screen 这一条在别处也被单列为一条检查项:视口稳定用 min-h-[100dvh]

三、Horizontal-Pan 骨架

"use client";
import { useRef, useEffect } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useReducedMotion } from "motion/react";

gsap.registerPlugin(ScrollTrigger);

export function HorizontalPan({ children }: { children: React.ReactNode }) {
  const wrap = useRef<HTMLDivElement>(null);
  const track = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    if (reduce || !wrap.current || !track.current) return;
    const ctx = gsap.context(() => {
      const distance = track.current!.scrollWidth - window.innerWidth;
      gsap.to(track.current, {
        x: -distance,
        ease: "none",
        scrollTrigger: {
          trigger: wrap.current,
          start: "top top",                              // pin starts when section top hits viewport top
          end: () => `+=${distance}`,                    // scroll distance = track width minus viewport
          pin: true,
          scrub: 1,
          invalidateOnRefresh: true,
        },
      });
    }, wrap);
    return () => ctx.revert();
  }, [reduce]);

  return (
    <section ref={wrap} className="relative overflow-hidden">
      <div ref={track} className="flex h-[100dvh] items-center">
        {children}
      </div>
    </section>
  );
}

原文给的关键点是四条:start: "top top"pin: trueend: "+=${distance}"(滚动长度等于需要的横向位移)、scrub: 1。外层被钉住,内层轨道随纵向滚动横向滑动。

结构上要分清两个 ref:wrap 是被 pin 的那一层,track 是真正位移的那一层。distancetrack.current!.scrollWidth - window.innerWidth 算出来,同一个值同时喂给了 x: -distanceend

两个细节和上一段不同。end 写成函数() => \+=${distance}`),而不是一个算好的字符串。**invalidateOnRefresh: true** 也只出现在这段骨架里。另外 scrub在这里是1,在 sticky-stack 里是 true`。

四、多数滚动动效根本不该用 GSAP

这是 §5 里更值钱的一条判断。原文给了第三段骨架 Scroll-Reveal Stagger,用的是 Motion 的 whileInView

<motion.li
  key={item}
  initial={reduce ? false : { opacity: 0, y: 24 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, amount: 0.3 }}
  transition={{
    duration: 0.6,
    delay: i * 0.06,
    ease: [0.16, 1, 0.3, 1],
  }}
>

适用场景原文列的是:功能列表、证言网格、logo 墙,任何只需要「滚动进场」的东西。理由是更轻、不需要 ScrollTrigger。结论那句话是这一节的核心:把 GSAP 留给真正需要 pin/scrub 的活。

所以决策路径其实很短:你需要钉住某个元素,或者需要动画进度绑死滚动条吗? 需要,才去翻上面那两段骨架;不需要,whileInViewonce: true 就是答案。反过来,一个只是「进场淡入」的列表却挂了一整套 ScrollTrigger,那属于工具选错了层级。

同一节还有一条相关约束:绝不在同一棵组件树里混用 GSAP / Three.js 与 Motion,原文给的理由是它们会抢同一批帧。与这条对得上的是,上面两段骨架里 motion/react 的出场也只到 useReducedMotion 为止,动画本身没有一处交给 Motion。

五、骨架之外,还有几条硬约束绕不开

照抄骨架不等于过关。同一份 SKILL.md 里有几条会直接判死的写法:

  • window.addEventListener("scroll", ...) 被禁,理由原文写的是每个滚动帧都跑、易卡顿、没有批处理。替代方案列了 Motion 的 useScroll()、GSAP 的 ScrollTrigger、IntersectionObserver,以及 CSS 滚动驱动动画(animation-timeline: view())。
  • 在 React state 里用 window.scrollY 自算滚动进度同样被禁,理由一致:每帧重渲染。
  • 触碰 React state 的 requestAnimationFrame 循环要改用 motion value(useMotionValue + useTransform)。
  • 只动 transformopacity,绝不动 topleftwidthheight
  • 任何 MOTION_INTENSITY > 3 的动效都必须尊重 prefers-reduced-motion,原文标注「这条不可谈判」。

还有一条叫 “Motion claimed, motion shown.”:MOTION_INTENSITY > 4 时页面必须真的动起来,声称 MOTION_INTENSITY: 7 的静态页面是坏的;而如果在现有范围内做不出可用的动效,原文的处置是把旋钮降到 3 并交付干净的静态页,绝不半吊子地做会坏的动效 —— 被截断的 ScrollTrigger、跳变的入场、缺失的清理,都被点名了。这条对应到工程上就是:与其硬撑一个 pin 不住的卡片堆,不如老老实实交静态版本。

六、Pre-Flight 里对应的是哪一行

那份 62 个复选框的最终检查清单我们另有一篇专门讲,这里只挑与本篇直接相关的一条,因为它就是这两段骨架的验收口径:GSAP 骨架是否符合 §5.A / §5.B。同一份清单里还有几条和上一节一一对应:无 window.addEventListener('scroll')MOTION_INTENSITY > 3 全部包了 reduced motion、useEffect 动画有严格清理函数、视口稳定用 min-h-[100dvh] 不用 h-screen、声称的动效真的存在、动效隔离在带 'use client' 的叶子组件并做了 memo。

把这几条拉到一起看就清楚了:骨架里那些看着像随手写的写法 —— ctx.revert()min-h-[100dvh]、首行的 "use client" —— 每一个在清单里都有独立的一格。骨架是清单的答案纸。

七、这些规则的性质,以及什么时候不照做

必须说清楚一件事:SKILL.md 是写给模型看的提示词,不是 lint 规则,不是 CI 检查,也不会在你的项目里执行。「start 必须是 top top」是一条指令,不是「装了这个 skill 你的代码里就不会出现 top center」的结果。中间隔着模型会不会照做。

但这一节和仓库里大部分禁令有个区别:它给的是可粘贴的具体代码。指令能不能生效不确定,一段代码对不对,你自己 diff 一眼就知道。真要拿它当保障,就得把上面几条落成你自己的 review 项或构建检查,而不是指望装个 skill 就到位——这是通用工程做法,不是 taste-skill 文档里写的内容。

至于什么时候可以不照做,§5 开头原文就给了口子:这些是工具不是默认,没有一条自动触发。 更上位的一条是 MOTION MUST BE MOTIVATED —— 加任何动画前先问「这个动画在传达什么」,有效答案是层级、叙事、反馈、状态转换,无效答案是「看着酷」;原文说因为 GSAP 可用就到处 GSAP 是外行做法,一句话说不清理由就砍掉这个动画。

顺着这条往回推:先判断这个滚动叙事该不该存在,再判断该不该用 GSAP,最后才是 start 写什么。 顺序反过来,top top 写得再对,也只是把一个不该做的动画做得更顺滑而已。


本文依据 taste-skill 官方仓库(github.com/Leonxlnx/taste-skill)的 README、CHANGELOG、 skills/ 下的 SKILL.md 与 .claude-plugin/ 清单整理,核对日 2026-08-09。 本文内容为仓库文档口径,我们没有安装或运行过其中任何一个 skill, 文中所有规则均为写给模型的提示词约束,不构成对输出结果的保证。 skill 内容随上游更新而变动,默认 skill 当前自标为 v2 (experimental) 且仍在迭代,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。