Skip to content

Instantly share code, notes, and snippets.

@Cozy228
Last active November 12, 2025 09:57
Show Gist options
  • Select an option

  • Save Cozy228/9aebbb7753edc8826f4bcfdef43063fc to your computer and use it in GitHub Desktop.

Select an option

Save Cozy228/9aebbb7753edc8826f4bcfdef43063fc to your computer and use it in GitHub Desktop.
gsap
// src/App.tsx
// 修复:FULL_HOLD 段视觉“完全静止”——在 holdStart~holdEnd 期间强制钳制到全屏几何与内容底部位移
import React, { useLayoutEffect, useRef } from "react";
import "./app.css";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useLenisGsap } from "./hooks/useLenisGsap";
import { IS_DEV } from "./utils/env";
import FpsHud from "./components/FpsHud";
import Card, { type CardData } from "./components/Card";
import { makeCards } from "./data/makeCards";
import {
INTRO_GAP, APPEAR, ZOOM, TEXT_FADE,
DOCK_MOVE, DOCK_EASE, BETWEEN, HIDE_FADE, FULL_HOLD,
DOCK_BASE_LEFT, DOCK_BASE_TOP, DOCK_GAP
} from "./animationConfig";
gsap.registerPlugin(ScrollTrigger);
// 统一生成
const cardsCfg: CardData[] = makeCards(4);
type Meta = {
card: HTMLElement;
contentWrap: HTMLElement;
contentInner: HTMLElement;
cover: HTMLElement;
// 关键时间点
tVisible: number;
tFullIn: number;
tFullOut: number;
tDockEnd: number;
// hold 钳制所需
holdStart: number;
holdEnd: number;
extra: number; // 假内滚位移量(scrollHeight - vh)
fullW: number;
fullH: number;
// 反向淡入所需
startLeft: number;
startTop: number;
};
export default function App() {
useLenisGsap();
const stageRef = useRef<HTMLDivElement>(null);
const ctxRef = useRef<gsap.Context | null>(null);
useLayoutEffect(() => {
const build = () => {
ctxRef.current?.revert();
ctxRef.current = gsap.context(() => {
const stage = stageRef.current!;
const tl = gsap.timeline({ defaults: { ease: "none" } });
let total = 0;
const metas: Meta[] = [];
// 初始空屏
tl.to({}, {}, total);
total += INTRO_GAP;
const cards = gsap.utils.toArray<HTMLElement>(".card");
cards.forEach((card, i) => {
const cover = card.querySelector<HTMLElement>('[data-role="cover"]')!;
const contentWrap = card.querySelector<HTMLElement>('[data-role="content"]')!;
const contentInner = card.querySelector<HTMLElement>('[data-role="content-inner"]')!;
// 重置
gsap.set(card, { clearProps: "x,y,scale,transform,opacity" });
gsap.set(contentWrap, { clearProps: "opacity" });
gsap.set(contentInner,{ clearProps: "y,transform" });
gsap.set(contentWrap, { opacity: 0, pointerEvents: "none" });
gsap.set(contentInner, { y: 0 });
gsap.set(cover, { opacity: 1 });
// 几何
const cs = getComputedStyle(card);
const baseW = parseFloat(cs.width);
const baseH = parseFloat(cs.height);
const vw = window.innerWidth;
const vh = window.innerHeight;
// 起点(右下)
const startLeft = Math.max(0, vw - baseW - 16);
const startTop = Math.max(0, vh - baseH - 16);
// 居中
const centerLeft = (vw - baseW) / 2;
const centerTop = (vh - baseH) / 2;
// Dock 目标(左上对角递增)
const dockLeft = DOCK_BASE_LEFT + i * DOCK_GAP;
const dockTop = DOCK_BASE_TOP + i * DOCK_GAP;
const tVisible = total;
// 设置起点几何
gsap.set(card, { left: startLeft, top: startTop, width: baseW, height: baseH, zIndex: 15 });
// 右下 → 居中
tl.to(card, { left: centerLeft, top: centerTop, duration: APPEAR, ease: "power4.out" }, total);
total += APPEAR;
// 居中 → 全屏(封面反向淡出)
tl.to(card, { left: 0, top: 0, width: vw, height: vh, duration: ZOOM, ease: "power1.inOut" }, total);
tl.to(cover, { opacity: 0, duration: ZOOM, ease: "power1.inOut" }, total);
total += ZOOM;
// 全屏:正文淡入
const tFullIn = total;
tl.set(contentInner, { y: 0 }, total);
tl.to(contentWrap, { opacity: 1, duration: TEXT_FADE, ease: "none" }, total);
total += TEXT_FADE;
// 全屏阅读(假内滚至底)
const extra = Math.max(0, contentInner.scrollHeight - window.innerHeight);
tl.to(contentInner, { y: -extra, duration: extra, ease: "none" }, total);
total += extra;
// ★ FULL_HOLD:不改变任何属性,仅消耗滚动;下方 onUpdate 中“钳制”视觉
const holdStart = total;
tl.to({}, { duration: FULL_HOLD }, total);
const holdEnd = holdStart + FULL_HOLD;
total += FULL_HOLD;
// 正文淡出
tl.to(contentWrap, { opacity: 0, duration: TEXT_FADE, ease: "none" }, total);
const tFullOut = total + TEXT_FADE;
total += TEXT_FADE;
// 退出全屏准备
tl.set(contentInner, { y: 0 }, total);
tl.set(cover, { opacity: 1 }, total);
// 全屏 → 居中
tl.to(card, { left: centerLeft, top: centerTop, width: baseW, height: baseH, duration: ZOOM, ease: "power1.inOut" }, total);
total += ZOOM;
// 居中 → Dock(左侧停靠)
tl.to(card, { left: dockLeft, top: dockTop, duration: DOCK_MOVE, ease: DOCK_EASE }, total);
const tDockEnd = total + DOCK_MOVE;
total += DOCK_MOVE;
metas.push({
card, contentWrap, contentInner, cover,
tVisible, tFullIn, tFullOut, tDockEnd,
holdStart, holdEnd, extra, fullW: vw, fullH: vh,
startLeft, startTop
});
total += BETWEEN;
});
// 可见性 / 层级 / 反向预淡出 + ★ hold 期间视觉钳制
ScrollTrigger.create({
animation: tl,
trigger: stage,
start: "top top",
end: () => "+=" + total,
scrub: 1,
pin: true,
anticipatePin: 1,
invalidateOnRefresh: true,
onUpdate(self) {
const t = tl.time();
const dir = self.direction;
metas.forEach(m => {
// —— 可见性(与之前一致)——
if (t >= m.tVisible) {
m.card.classList.add("is-visible");
m.card.classList.remove("invisible");
m.card.style.opacity = "";
} else if (dir === -1 && t > m.tVisible - HIDE_FADE) {
const alpha = (t - (m.tVisible - HIDE_FADE)) / HIDE_FADE;
m.card.classList.add("is-visible");
m.card.classList.remove("invisible");
m.card.style.opacity = String(alpha);
m.card.style.left = m.startLeft + "px";
m.card.style.top = m.startTop + "px";
} else {
m.card.classList.remove("is-visible");
m.card.classList.add("invisible");
m.card.style.opacity = "";
}
// —— 层级(与之前一致)——
const phase =
t >= m.tDockEnd ? 3 :
t >= m.tFullIn && t < m.tFullOut ? 2 :
t >= m.tVisible ? 1 : 0;
if (phase === 2) m.card.style.zIndex = "20";
else if (phase === 3) m.card.style.zIndex = "12";
else if (phase === 1) m.card.style.zIndex = "15";
else m.card.style.zIndex = "0";
// —— ★ HOLD 钳制:在 holdStart~holdEnd 期间,强制维持“全屏几何 + 内容底部”
if (t >= m.holdStart && t < m.holdEnd) {
// 全屏几何
if (
m.card.style.left !== "0px" || m.card.style.top !== "0px" ||
m.card.style.width !== m.fullW + "px" || m.card.style.height !== m.fullH + "px"
) {
m.card.style.left = "0px";
m.card.style.top = "0px";
m.card.style.width = m.fullW + "px";
m.card.style.height = m.fullH + "px";
}
// 内容锁定在底部
const target = -m.extra;
// 直接写 transform,避免 CSS 类干扰
(m.contentInner.style as any).transform = `translateY(${target}px)`;
// 确保正文可见(在淡出前)
m.contentWrap.style.opacity = "1";
}
});
}
});
ScrollTrigger.refresh();
}, stageRef);
};
build();
// 重建
let rafId = 0;
const onResize = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
ctxRef.current?.revert();
build();
});
};
window.addEventListener("resize", onResize);
const onLoad = () => ScrollTrigger.refresh();
window.addEventListener("load", onLoad);
return () => {
window.removeEventListener("resize", onResize);
window.removeEventListener("load", onLoad);
ctxRef.current?.revert();
};
}, []);
return (
<div className="min-h-[200vh] bg-bg text-fg">
{IS_DEV && <FpsHud />}
<header className="h-[80vh] grid place-items-center text-center text-muted">
<div>
<h1 className="m-0 mb-2 text-fg">GSAP + Lenis + Tailwind v4</h1>
<p>滚动以开始</p>
</div>
</header>
<section className="relative h-[100vh] overflow-hidden border-y border-white/10" ref={stageRef}>
<div className="relative w-full h-full">
{cardsCfg.map((c) => <Card key={c.id} data={c} />)}
</div>
</section>
<footer className="h-[120vh] grid place-items-center text-muted">
<p>结束</p>
</footer>
</div>
);
}
// src/components/Card.tsx
import React, { forwardRef, CSSProperties } from "react";
import {
ResponsiveContainer,
LineChart, Line,
AreaChart, Area,
BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, Legend
} from "recharts";
export type ChartSpec = {
type: "line" | "area" | "bar";
data: Array<{ name: string; value: number }>;
yDomain?: [number, number];
};
export type CardData = {
id: string;
title: string;
body: string[]; // 纯文本段落
coverLabel?: string;
width?: string; // 默认 520px
height?: string; // 默认 340px
chart?: ChartSpec; // 可选:使用 Recharts 绘制简单图形
};
export type CardProps = {
data: CardData;
className?: string;
style?: CSSProperties;
};
const AXIS = { stroke: "#9aa4b2" as const };
const GRID = { stroke: "rgba(148,163,184,.25)" as const };
const Card = forwardRef<HTMLElement, CardProps>(({ data, className, style }, ref) => {
const { title, body, coverLabel, width = "520px", height = "340px", chart } = data;
return (
<article
ref={ref as any}
className={["card", className].filter(Boolean).join(" ")}
style={{ width, height, ...(style || {}) }}
data-card-id={data.id}
>
<div className="card__inner">
{/* 封面:放大过程中由时间线反向淡出 */}
<div className="card__cover" aria-hidden="true">
<div className="cover-icon" role="img" aria-label="cover icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor" aria-hidden="true">
<path d="M4 6h16v2H4zM4 11h16v2H4zM4 16h10v2H4z"></path>
</svg>
</div>
<div className="cover-text">{coverLabel ?? title}</div>
</div>
{/* 正文:仅在全屏阶段淡入;内部“假内滚”由 .content-inner 的 translateY 驱动 */}
<div className="card__content">
<div className="content-inner">
<h2>{title}</h2>
{body.map((t, idx) => <p key={idx}>{t}</p>)}
{chart && (
<div className="chart-wrap">
<ResponsiveContainer width="100%" height={220}>
{chart.type === "line" ? (
<LineChart data={chart.data} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID.stroke} strokeDasharray="3 3" />
<XAxis dataKey="name" stroke={AXIS.stroke} tick={{ fill: AXIS.stroke }} />
<YAxis domain={chart.yDomain} stroke={AXIS.stroke} tick={{ fill: AXIS.stroke }} />
<Tooltip
contentStyle={{ background: "rgba(15,23,42,.9)", border: "1px solid rgba(148,163,184,.25)" }}
labelStyle={{ color: "#e5e7eb" }}
itemStyle={{ color: "#e5e7eb" }}
/>
<Legend />
<Line type="monotone" dataKey="value" dot={false} />
</LineChart>
) : chart.type === "area" ? (
<AreaChart data={chart.data} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID.stroke} strokeDasharray="3 3" />
<XAxis dataKey="name" stroke={AXIS.stroke} tick={{ fill: AXIS.stroke }} />
<YAxis domain={chart.yDomain} stroke={AXIS.stroke} tick={{ fill: AXIS.stroke }} />
<Tooltip
contentStyle={{ background: "rgba(15,23,42,.9)", border: "1px solid rgba(148,163,184,.25)" }}
labelStyle={{ color: "#e5e7eb" }}
itemStyle={{ color: "#e5e7eb" }}
/>
<Legend />
<Area type="monotone" dataKey="value" />
</AreaChart>
) : (
<BarChart data={chart.data} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID.stroke} strokeDasharray="3 3" />
<XAxis dataKey="name" stroke={AXIS.stroke} tick={{ fill: AXIS.stroke }} />
<YAxis domain={chart.yDomain} stroke={AXIS.stroke} tick={{ fill: AXIS.stroke }} />
<Tooltip
contentStyle={{ background: "rgba(15,23,42,.9)", border: "1px solid rgba(148,163,184,.25)" }}
labelStyle={{ color: "#e5e7eb" }}
itemStyle={{ color: "#e5e7eb" }}
/>
<Legend />
<Bar dataKey="value" />
</BarChart>
)}
</ResponsiveContainer>
</div>
)}
{/* 留白,方便演示滚动到底部 */}
<div style={{ height: "40vh" }} />
</div>
</div>
</div>
</article>
);
});
Card.displayName = "Card";
export default Card;
// src/services/cardsApi.ts
import type { CardData } from "../components/Card";
const repeat = (s: string, n: number) => Array.from({ length: n }, () => s);
const mkSeries = (n = 16, base = 50, jitter = 12) =>
Array.from({ length: n }, (_, i) => ({
name: `t${i + 1}`,
value: Math.max(0, Math.round(base + (Math.random() * 2 - 1) * jitter))
}));
export async function fetchCardsDummy(): Promise<CardData[]> {
await new Promise(r => setTimeout(r, 120));
const longParas = [
...repeat("示例文案占位,演示滚动与全屏阶段的文本段落。", 4),
...repeat("进入全屏后正文从顶部淡入;到底部先淡出再退出全屏。", 4),
...repeat("固定卡片尺寸,内部采用 translateY 实现假内滚。", 4),
];
const mk = (id: string, title: string, chartType: "line" | "area" | "bar", base: number): CardData => ({
id,
title,
coverLabel: "Overview",
width: "520px",
height: "340px",
body: [
"段落:仅在全屏时显示正文;阅读结束再退出全屏。",
...longParas,
"下方为 Recharts 图形(假数据):"
],
chart: {
type: chartType,
data: mkSeries(18, base, 15),
yDomain: [0, 100]
}
});
return [
mk("c1", "Card 1", "line", 58),
mk("c2", "Card 2", "area", 42),
mk("c3", "Card 3", "bar", 66)
];
}
/* 在原有 styles.css 里追加(可选) */
.chart-wrap{ margin:12px 0 6px; height:220px; }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment