diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 5b280df17..85089e8eb 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -1499,6 +1499,44 @@ describe("GSAP rules", () => {
expect(finding).toBeDefined();
});
+ it("gsap_non_transform_motion: fires on text-reflow props (letterSpacing / fontSize)", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const findings = result.findings.filter((f) => f.code === "gsap_non_transform_motion");
+ expect(findings).toHaveLength(2);
+ expect(findings.every((f) => f.severity === "error")).toBe(true);
+ });
+
+ it("gsap_non_transform_motion: text-reflow props are NOT html-in-canvas-exempt", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_non_transform_motion");
+ expect(finding).toBeDefined();
+ });
+
it("gsap_non_transform_motion: does NOT fire on the literal text 'roundProps:' inside a string", async () => {
const html = `
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index 42b75d66f..a78c321d6 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -1160,8 +1160,7 @@ export const gsapRules: LintRule[] = [
);
return matched.length > 0 && matched.every(isHtmlInCanvas);
};
- // Map each flagged layout prop to its transform replacement axis. roundProps is
- // handled separately (it has no positional replacement — the fix is to remove it).
+ // Positional layout props → each maps to its transform replacement axis (x/y).
const LAYOUT_FIX: Record = {
left: ["x"],
right: ["x"],
@@ -1173,6 +1172,13 @@ export const gsapRules: LintRule[] = [
marginTop: ["y"],
marginBottom: ["y"],
};
+ // Text-reflow props: animating them reflows text and snaps glyph positions to the
+ // pixel grid, stuttering on slow motion exactly like positional props. They have no
+ // transform replacement (the fix is to not animate them — settle via scale or hold the
+ // value), and the snap happens during browser layout, UPSTREAM of any canvas raster, so
+ // they are never html-in-canvas-exempt. (width/height are deliberately omitted: they
+ // have legitimate animated uses — progress bars, reveals — and would over-report.)
+ const REFLOW_PROPS = ["letterSpacing", "wordSpacing", "fontSize"];
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
@@ -1201,36 +1207,40 @@ export const gsapRules: LintRule[] = [
for (const call of calls) {
// set() is instantaneous — it never animates, so it cannot stutter.
if (call.method === "set") continue;
- const usesRoundProps = call.properties.includes("roundProps");
// Object.hasOwn, not `in`: a tween property named `toString`/`constructor` would
// match the prototype chain and resolve LAYOUT_FIX[p] to an inherited function.
let layoutProps = call.properties.filter((p) => Object.hasOwn(LAYOUT_FIX, p));
- // html-in-canvas elements don't integer-snap on layout props (canvas reads
- // sub-pixel computed left/top — see EXEMPTION above). roundProps is NOT exempt:
- // it rounds the value BEFORE it reaches the style, so the canvas reads the rounded
- // value and still stutters (matching this rule's "even on transforms" message).
+ const reflowProps = call.properties.filter((p) => REFLOW_PROPS.includes(p));
+ const usesRoundProps = call.properties.includes("roundProps");
+ // Only positional props are html-in-canvas-exempt: the canvas positions the draw
+ // from sub-pixel computed left/top. Reflow props (glyph layout) and roundProps
+ // (value rounding) snap upstream of the raster, so they always fire.
if (layoutProps.length > 0 && allTargetsHtmlInCanvas(call.selector)) layoutProps = [];
- if (layoutProps.length === 0 && !usesRoundProps) continue;
+ if (layoutProps.length === 0 && reflowProps.length === 0 && !usesRoundProps) continue;
+ const flagged = [...layoutProps, ...reflowProps, ...(usesRoundProps ? ["roundProps"] : [])];
const message =
- layoutProps.length > 0
- ? `GSAP tween animates layout propert${layoutProps.length > 1 ? "ies" : "y"} ` +
- `${layoutProps.join(", ")}${usesRoundProps ? " with roundProps" : ""} on ` +
- `"${call.selector}". Layout properties snap to integer device pixels, so slow motion ` +
- "(or an ease-out tail) stutters under the seek-by-frame capture engine. Animate " +
- "transforms instead."
- : `GSAP tween uses roundProps on "${call.selector}", which snaps animated values to ` +
- "whole integers. Integer snapping stutters under slow motion on the seek-by-frame " +
- "capture engine, even on transforms.";
+ `GSAP tween on "${call.selector}" uses motion that snaps to integer device pixels: ` +
+ `${flagged.join(", ")}. Layout and text-reflow properties snap during browser layout; ` +
+ "roundProps rounds the tween value. Slow motion or an ease-out tail then stutters under " +
+ "the seek-by-frame capture engine — animate transforms (x/y/scale/opacity) instead.";
- const fixTokens = [...new Set(layoutProps.flatMap((p) => LAYOUT_FIX[p] ?? []))];
- const fixHint =
- layoutProps.length > 0
- ? `Replace ${layoutProps.join("/")} with the transform equivalent (${fixTokens.join(", ")})` +
- `${usesRoundProps ? " and remove roundProps" : ""}, e.g. ` +
- `tl.fromTo("${call.selector}", { x: -1300 }, { x: 0, ...yourAnimation }). ` +
- "Transforms interpolate sub-pixel and stay smooth at any speed."
- : "Remove roundProps. Let transforms (x/y/scale) interpolate sub-pixel for smooth motion.";
+ const fixes: string[] = [];
+ if (layoutProps.length > 0) {
+ const tokens = [...new Set(layoutProps.flatMap((p) => LAYOUT_FIX[p] ?? []))];
+ fixes.push(
+ `replace ${layoutProps.join("/")} with the transform equivalent (${tokens.join(", ")}) — ` +
+ `e.g. tl.fromTo("${call.selector}", { x: -1300 }, { x: 0, ...yourAnimation })`,
+ );
+ }
+ if (reflowProps.length > 0) {
+ fixes.push(
+ `do not animate ${reflowProps.join("/")} (they reflow text and snap glyph positions) — ` +
+ "settle via scale, or set the final value statically",
+ );
+ }
+ if (usesRoundProps) fixes.push("remove roundProps");
+ const fixHint = `${fixes.join("; ")}. Transforms interpolate sub-pixel and stay smooth at any speed.`;
findings.push({
code: "gsap_non_transform_motion",
diff --git a/registry/components/vignette/demo.html b/registry/components/vignette/demo.html
index d96e7ef03..9e86d0030 100644
--- a/registry/components/vignette/demo.html
+++ b/registry/components/vignette/demo.html
@@ -181,11 +181,14 @@
tl.to(".haze-near", { opacity: 1, duration: 0.8, ease: "power2.out" }, 0.15);
tl.to(".demo-subject", { opacity: 1, scale: 1, duration: 1.0, ease: "power3.out" }, 0.1);
- // Title + subtitle settle in.
+ // Title + subtitle settle in. Settle via scale (transform), not letterSpacing:
+ // animating letter-spacing reflows text and snaps glyph positions to the pixel
+ // grid, so a slow ease-out tail micro-stutters. letter-spacing holds at its CSS
+ // resting value (0.18em); the subtle scale gives the same "settle into place" feel.
tl.fromTo(
".demo-title",
- { opacity: 0, y: 18, letterSpacing: "0.32em" },
- { opacity: 1, y: 0, letterSpacing: "0.18em", duration: 1.0, ease: "power3.out" },
+ { opacity: 0, y: 18, scale: 1.04 },
+ { opacity: 1, y: 0, scale: 1, duration: 1.0, ease: "power3.out" },
0.6,
);
tl.to(".demo-subtitle", { opacity: 1, duration: 0.8, ease: "power3.out" }, 1.0);
diff --git a/skills-manifest.json b/skills-manifest.json
index b1371c40c..7bb8a33d9 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -18,7 +18,7 @@
"files": 1
},
"hyperframes-animation": {
- "hash": "9f0ccb60ff53e739",
+ "hash": "b15d63381aab5852",
"files": 115
},
"hyperframes-cli": {
diff --git a/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md b/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md
index 2dc673351..d24b086e4 100644
--- a/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md
+++ b/skills/hyperframes-animation/adapters/gsap-transforms-and-perf.md
@@ -59,10 +59,12 @@ Animate any custom property. Works for color, length, number — anything CSS wi
### Animate transforms, not layout properties
-Animate `x`, `y`, `scale`, `rotation`, `opacity`. Never animate `left`, `right`, `top`, `bottom`, `width`, `height`, `margin*` — and never `roundProps`.
+Animate `x`, `y`, `scale`, `rotation`, `opacity`. Never animate `left`, `right`, `top`, `bottom`, `width`, `height`, `margin*`, the text-reflow props `letterSpacing` / `wordSpacing` / `fontSize` — and never `roundProps`.
This is a **render-correctness** rule in HyperFrames, not just a GPU-performance nicety. The renderer seeks frame-by-frame and screenshots each frame, and the browser compositor snaps layout properties to whole device pixels. On a fast tween the per-frame step is several pixels, so the snap is invisible; on a slow tween or a long ease-out tail the value moves less than a pixel per frame — it holds the same pixel for several frames, then jumps a whole one. The result is motion that looks smooth when fast but visibly stutters when slow. Transforms interpolate sub-pixel and stay smooth at any speed. `roundProps` forces the same integer snap onto a transform — don't use it.
+"Layout property" is broader than position: anything that triggers **reflow** snaps the same way. `letterSpacing` / `fontSize` are the common trap — a slow "settle" that crawls letter-spacing or font-size by a fraction of a pixel per frame dwells on a handful of discrete glyph layouts (visible micro-stutter). For a text settle, animate `scale` (or hold the final value) instead. Unlike positional props, reflow props snap during browser **layout** — upstream of the canvas raster — so they stutter even in html-in-canvas, and the exception below does **not** apply to them.
+
**Convert a position animation to a transform** by leaving the element at its resting `left`/`top` in CSS and animating the _offset_ with `x`/`y`:
```javascript