-
-
Save Cozy228/9aebbb7753edc8826f4bcfdef43063fc to your computer and use it in GitHub Desktop.
gsap
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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> | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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