DEV Community

Dhardingsea Developer
Dhardingsea Developer

Posted on

I added scroll animations to three live pages — and silently corrupted two of them

What I was adding

Three DHSeaDev project pages — Dota Companion, Annoying Dino, CaptureTools — were due for scroll animation. Nothing exotic: GSAP 3.12.5 plus ScrollTrigger, cards fading up as they enter the viewport, a screenshot strip that parallaxes on scroll. GSAP went fully free for every plugin at some point this year, which is the only reason this was worth doing at all.

The site has no build step and no staging environment. Every page is a single WordPress Custom HTML block, and every edit is a raw REST write against the live URL. That constraint is what made the next two hours interesting.

The pin that would not hold still

Before touching a real page, I ran a spike on a private draft: pin a card in place while the user scrolls past it, the standard ScrollTrigger pin: true pattern. It failed in a specific way — the pinned element didn't stay pinned. It drifted, about 1,260px over the course of the scroll, like the browser was measuring its position against the wrong coordinate space.

It was. The site's page wrapper does a full-bleed break-out:

.dhsea-page {
  position: relative;
  width: 100vw;
  left: 50%;
  transform: translateX(-50%);
}
Enter fullscreen mode Exit fullscreen mode

Any element with a transform on it becomes a new containing block for its fixed/absolutely-positioned descendants. ScrollTrigger's pin mechanism auto-detects which positioning strategy to use, and its detection doesn't account for an ancestor transform three levels up. It picked the wrong one and pinned against the wrong reference frame.

I didn't trust that read until I had a control. Same test, same page structure, no transform wrapper — the pin held at the exact same offset across the whole scroll range. Then the fix, on the transformed version:

ScrollTrigger.create({
  trigger: '#target',
  pin: true,
  pinType: 'transform',   // stop guessing
  pinReparent: true,
});
Enter fullscreen mode Exit fullscreen mode

Rock solid, matching the control exactly. On this site, any pinned ScrollTrigger needs pinType set explicitly — auto-detect is not safe inside a transformed containing block, and it fails silently enough that you won't notice until you're staring at drift.

The bug my own length check couldn't see

The actual write recipe for a single-block page looks like this: fetch the block's raw content, splice new markup in at a verified anchor, check that the output is exactly original length + inserted length longer, then POST it back. That last check is supposed to be the safety net — if the delta doesn't match exactly, something touched more than intended.

It passed. Every time. And it was still wrong.

Here's the part I missed: the page's content.raw already comes back from WordPress with its own <!-- wp:html -->...<!-- /wp:html --> wrapper — sometimes three separate ones, stitched together. My splice was correct. But then, out of habit, I wrapped the result in a fresh pair of those comments before sending it, because that's what you do when you're building a block from scratch. I wasn't building one from scratch. I was editing one that already had its wrapper attached.

The length math still balanced, because I was adding a fixed, known amount of text and checking for exactly that much growth — the check never asked where the growth was structurally valid, only that the total was right. The result: two pages ended up with a duplicated block boundary, four open comments and four closes where there should have been three of each.

Nothing rendered wrong. WordPress strips every <!-- wp:* --> comment at render time, so the live HTML was byte-identical either way. That's exactly why it survived a "did the page look right" check. It only surfaced when I diffed an independent re-fetch of the stored content against the literal string I'd sent — same length, same bytes, and still wrong, because "wrong" here was a structural property the byte-length gate was never built to see.

// wrong: re-wraps content that's already wrapped
const body = { content: '<!-- wp:html -->\n' + out + '\n<!-- /wp:html -->' };

// right: content.raw already carries its own wrapper(s) — send the splice directly
const body = { content: out };
Enter fullscreen mode Exit fullscreen mode

The fix was mechanical once I saw it: strip the one extra layer, re-POST, re-verify block count. The lesson is the gate, not the bug — a length check only proves the size is right. It takes a structural check (here, counting block-comment pairs) to prove the shape is right, and I'd been treating "the math balances" as proof of both.

A "free" plugin that was not

The last page, CaptureTools, called for a small SVG path-draw animation on its icon — GSAP's DrawSVGPlugin, part of the "everything's free now" bundle. Except it isn't served from the same place as the rest of GSAP. The plain gsap package 404s on it, on both CDNs I tried. It only resolves from a package literally named gsap-trial.

I opened the file before shipping it. No hard expiry that I could find, but the internals still reference "trial" and "club" — leftovers from before the license changed, maybe, or maybe not. Either way, I wasn't going to ship a script named trial to a page real visitors load, on the strength of "probably fine." Swapped it for a plain scale-and-fade reveal using the ScrollTrigger already on the page. Same restraint, no unverified dependency.

What I'd verify differently next time

Three separate failures, three different shapes: a physics assumption that broke against a CSS property three ancestors away, a correctness check that measured the wrong dimension of "correct," and a dependency that was free in spirit but not in its actual distribution. None of them showed up by looking at the rendered page. All three showed up by re-fetching the thing I'd actually stored or actually shipped, and checking it against something more specific than "is it the right size" or "does it look fine."

The length gate isn't wrong. It's just not sufficient on its own — and on a site with no staging environment, "sufficient" is the only bar that matters, because there's no second chance to catch it before a visitor does.

Found while shipping dhseadev.online's GSAP rollout, September 2026.

Top comments (0)