`start: "top top"` 写错就毁了:两个 GSAP 规范骨架逐行读
本文所有仓库信息核对日为 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 center 和 top 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: true、pinSpacing: false),end 也是 "top top",终点绑在最后一张卡上。第二个是 gsap.to,负责 scale: 0.92 和 opacity: 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: true、end: "+=${distance}"(滚动长度等于需要的横向位移)、scrub: 1。外层被钉住,内层轨道随纵向滚动横向滑动。
结构上要分清两个 ref:wrap 是被 pin 的那一层,track 是真正位移的那一层。distance 由 track.current!.scrollWidth - window.innerWidth 算出来,同一个值同时喂给了 x: -distance 和 end。
两个细节和上一段不同。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 的活。
所以决策路径其实很短:你需要钉住某个元素,或者需要动画进度绑死滚动条吗? 需要,才去翻上面那两段骨架;不需要,whileInView 加 once: 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)。 - 只动
transform和opacity,绝不动top、left、width、height。 - 任何
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) 且仍在迭代,请以仓库最新内容为准。