diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 1ef53fa7b8..82d17fddcb 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -3139,3 +3139,117 @@ describe("SVG draw-on rules", () => {
expect(finding).toBeUndefined();
});
});
+
+describe("gsap_fromto_flash_before_start", () => {
+ const comp = (script: string, body = "", style = "") => `
+
+ ${body}
+ ${style ? `` : ""}
+
+
+`;
+
+ it("errors when a late fromTo jumps from a non-identity from-var on a visible element", async () => {
+ const result = await lintHyperframeHtml(
+ comp(
+ `tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, ease: "steps(12)" }, 2);`,
+ `code
`,
+ ),
+ );
+ const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start");
+ expect(finding).toBeDefined();
+ expect(finding!.severity).toBe("error");
+ expect(finding!.selector).toBe("#line");
+ });
+
+ it("errors when a late fromTo fades in an element that was never hidden", async () => {
+ const result = await lintHyperframeHtml(
+ comp(
+ `tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 1.5);`,
+ `Hi
`,
+ ),
+ );
+ const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start");
+ expect(finding).toBeDefined();
+ });
+
+ it("exempts a target hidden by authored CSS opacity:0", async () => {
+ const result = await lintHyperframeHtml(
+ comp(
+ `tl.fromTo("#line", { x: "-105%" }, { x: "0%", opacity: 1, duration: 0.8 }, 2);`,
+ `code
`,
+ `#line { opacity: 0; }`,
+ ),
+ );
+ expect(
+ result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
+ ).toBeUndefined();
+ });
+
+ it("exempts a target pre-hidden by a timeline set at the timeline start", async () => {
+ const result = await lintHyperframeHtml(
+ comp(
+ `tl.set("#line", { opacity: 0 }, 0);
+ tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`,
+ `code
`,
+ ),
+ );
+ expect(
+ result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
+ ).toBeUndefined();
+ });
+
+ it("exempts a target pre-hidden by a standalone gsap.set", async () => {
+ const result = await lintHyperframeHtml(
+ comp(
+ `gsap.set("#line", { opacity: 0 });
+ tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`,
+ `code
`,
+ ),
+ );
+ expect(
+ result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
+ ).toBeUndefined();
+ });
+
+ it("exempts identity from-vars (no jump)", async () => {
+ const result = await lintHyperframeHtml(
+ comp(
+ `tl.fromTo("#drift", { x: 0 }, { x: 100, duration: 3 }, 5);`,
+ `Hi
`,
+ ),
+ );
+ expect(
+ result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
+ ).toBeUndefined();
+ });
+
+ it("exempts immediateRender: true (from-vars hold from load)", async () => {
+ const result = await lintHyperframeHtml(
+ comp(
+ `tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, immediateRender: true }, 2);`,
+ `code
`,
+ ),
+ );
+ expect(
+ result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
+ ).toBeUndefined();
+ });
+
+ it("exempts tweens at the timeline start (zero flash window)", async () => {
+ const result = await lintHyperframeHtml(
+ comp(
+ `tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 0);`,
+ `code
`,
+ ),
+ );
+ expect(
+ result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"),
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index 92290d9cf8..06e7f07775 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -1818,6 +1818,94 @@ export const gsapRules: LintRule[] = [
return findings;
},
+ // gsap_fromto_flash_before_start — a fromTo() positioned after the timeline's start
+ // renders with GSAP's default immediateRender: false. The target therefore sits in
+ // its AUTHORED state from t=0, then jumps to the tween's from-vars when it begins:
+ // a visible flash/jump on every playback and every cold seek. (Real-world casualty:
+ // a "typewriter" whose lines were fully readable before each line "typed".)
+ // Exemptions: the authored state is already hidden (CSS opacity:0, standalone
+ // gsap.set, or a timeline set-to-hidden at the timeline start) — nothing to flash;
+ // the from-vars are the transform/opacity identity — no jump; immediateRender: true —
+ // GSAP applies the from-vars at build time and holds them; and tweens starting at
+ // the timeline start, whose flash window is zero.
+ async ({ scripts, styles, tags }) => {
+ const findings: HyperframeLintFinding[] = [];
+ const cssHiddenSelectors = collectCssOpacityZeroSelectors(styles, tags);
+ const TRANSFORM_IDENTITY: Record = {
+ x: 0,
+ y: 0,
+ rotation: 0,
+ rotationX: 0,
+ rotationY: 0,
+ skewX: 0,
+ skewY: 0,
+ scale: 1,
+ scaleX: 1,
+ scaleY: 1,
+ };
+ for (const script of scripts) {
+ if (!/gsap\.timeline/.test(script.content)) continue;
+ const windows = await cachedExtractGsapWindows(script.content);
+ const preHidden = new Set([
+ ...cssHiddenSelectors,
+ ...extractStandaloneHiddenSelectors(script.content),
+ ]);
+ // A timeline set-to-hidden at the timeline start pre-hides the target for every
+ // seek; sets at later positions leave [0, position) visible, so they do not.
+ const initialHiddenSets = new Set(
+ windows
+ .filter(
+ (w) =>
+ w.method === "set" &&
+ w.position <= SCENE_BOUNDARY_EPSILON_SECONDS &&
+ isHiddenGsapState(w.propertyValues),
+ )
+ .map((w) => w.targetSelector),
+ );
+ for (const win of windows) {
+ if (win.method !== "fromTo") continue;
+ if (win.immediateRender) continue;
+ if (win.position <= SCENE_BOUNDARY_EPSILON_SECONDS) continue;
+ if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue;
+ const sel = win.targetSelector;
+ const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
+ if (preHidden.has(cssKey) || initialHiddenSets.has(sel)) continue;
+ const from = win.fromPropertyValues;
+ if (!from) continue;
+ const jumps = Object.entries(from).some(([prop, value]) => {
+ const identity = TRANSFORM_IDENTITY[prop];
+ if (identity !== undefined) {
+ const n = numberValue(value);
+ return n === null ? true : n !== identity;
+ }
+ if (prop === "opacity" || prop === "autoAlpha") {
+ const n = numberValue(value);
+ return n === null ? true : n !== 1;
+ }
+ return prop === "visibility" || prop === "display";
+ });
+ if (!jumps) continue;
+ findings.push({
+ code: "gsap_fromto_flash_before_start",
+ severity: "error",
+ message:
+ `"${sel}" is visible from t=0 in its authored state, then jumps to this ` +
+ `tween's from-vars at ${win.position.toFixed(2)}s (timeline fromTo defaults ` +
+ "to immediateRender: false) — a visible flash on every playback and cold seek.",
+ selector: sel,
+ fixHint:
+ `Hide the authored state until the tween starts (CSS \`opacity: 0\` on "${sel}" ` +
+ 'with `opacity: 1` in the destination vars, or `tl.set("' +
+ sel +
+ '", { opacity: 0 }, 0)`), or pass `immediateRender: true` so the ' +
+ "from-vars hold from load.",
+ snippet: truncateSnippet(win.raw),
+ });
+ }
+ }
+ return findings;
+ },
+
// gsap_non_transform_motion — animating layout props (left/top/right/bottom/margin*)
// or using roundProps snaps motion to integer device pixels. On the seek-by-frame
// capture engine this looks smooth at high per-frame deltas (fast tweens) but visibly