<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Keymel Gaston</title>
    <description>The latest articles on DEV Community by Keymel Gaston (@keymelgaston).</description>
    <link>https://dev.to/keymelgaston</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4091311%2F202f74be-1cfe-4d87-9adf-146f414eb066.jpg</url>
      <title>DEV Community: Keymel Gaston</title>
      <link>https://dev.to/keymelgaston</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/keymelgaston"/>
    <language>en</language>
    <item>
      <title>4 Motion Design Bugs That Break Astro + GSAP + Lenis Sites in Production</title>
      <dc:creator>Keymel Gaston</dc:creator>
      <pubDate>Sun, 23 Aug 2026 22:04:20 +0000</pubDate>
      <link>https://dev.to/keymelgaston/4-motion-design-bugs-that-break-astro-gsap-lenis-sites-in-production-31ac</link>
      <guid>https://dev.to/keymelgaston/4-motion-design-bugs-that-break-astro-gsap-lenis-sites-in-production-31ac</guid>
      <description>&lt;p&gt;I got tired of debugging the same four things every time I built a motion-design landing page with Astro, GSAP, and Lenis — so I wrote them down. Here's each bug, what causes it, and how to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Scroll-triggered animations that never fire
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How to spot it:&lt;/strong&gt; you scroll to a section, and the animation that's supposed to play doesn't. No console error — the element just sits in its "before" state forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause:&lt;/strong&gt; Lenis replaces native scrolling with a virtual, smoothed one. GSAP's ScrollTrigger has no built-in awareness that Lenis exists — it's still listening for native scroll behavior Lenis has effectively taken over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;lenis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ScrollTrigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;lenis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lagSmoothing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do this once, at setup. Do it per-section instead and you'll eventually forget one — which is exactly how this bug ships to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Images that fade in as blank boxes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How to spot it:&lt;/strong&gt; a smooth, well-eased fade-in animation... revealing nothing. The image pops in abruptly partway through, or right after the animation finishes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause:&lt;/strong&gt; the animation triggers on &lt;code&gt;DOMContentLoaded&lt;/code&gt;, component mount, or scroll-into-view — none of which guarantee the image has actually finished downloading and decoding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;?.().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;load&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Await this before starting the fade. One helper function, one fewer confusing bug report.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Buttons that stop responding for no visible reason
&lt;/h2&gt;

&lt;p&gt;Two separate causes here — worth checking both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3a — a decorative layer is eating the click.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An animated background shape or overlay ends up in a higher stacking context than an interactive element near it. The button looks normal. It just doesn't respond.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- z-index left to chance, decided per component
&lt;/span&gt;&lt;span class="gi"&gt;+ pointer-events: none on every purely decorative element, always
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3b — a React/Vue island hasn't hydrated yet.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With lazy hydration (&lt;code&gt;client:visible&lt;/code&gt;, &lt;code&gt;client:idle&lt;/code&gt;), there's a real window where an island's HTML is on the page but its listeners haven't attached. A click in that window isn't delayed — it's lost.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hydrated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setHydrated&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setHydrated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hydrated&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hydrated&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Click me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Loading…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't just hope the click lands after hydration — disable the element until hydration is confirmed. This race can pass casual manual testing and still fail reliably under real load — it's the kind of thing that's easy to miss until you have automated tests hitting it under real concurrency.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. SEO and Lighthouse scores that don't hold up
&lt;/h2&gt;

&lt;p&gt;The most common individual causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;og:image&lt;/code&gt; set to a relative URL — silently fails the Open Graph spec, which requires an absolute URL&lt;/li&gt;
&lt;li&gt;No base URL configured, so canonical links can't resolve&lt;/li&gt;
&lt;li&gt;Missing alt text, skipped heading levels, no sitemap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; centralize your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; in one layout every page uses, resolve image and canonical URLs to absolute paths explicitly, and generate a sitemap.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest note on Lighthouse scores
&lt;/h2&gt;

&lt;p&gt;If you're running Lighthouse on a site with real scroll-driven animation and you're not hitting a perfect 100 on Performance — that's expected. A 100 with heavy motion actually running is rare; it usually means the animation isn't doing much. A defensible 80s–90s score with genuine motion design running is more honest than a suspiciously perfect one.&lt;/p&gt;




&lt;p&gt;I put all four of these — tested, with automated regression tests, demonstrated live rather than just described — into a full Astro + GSAP + Lenis landing page template. &lt;a href="https://landing-premium-kit.vercel.app" rel="noopener noreferrer"&gt;The live demo is here&lt;/a&gt; if you want to see them in action, and the &lt;a href="https://keymelgaston.gumroad.com/l/inkukz" rel="noopener noreferrer"&gt;kit itself is on Gumroad&lt;/a&gt; if you'd rather start from a base that already has this solved.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of these in the comments — especially the hydration race in #3, that one surprised me too.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>css</category>
    </item>
  </channel>
</rss>
