Home page 'Three Steps' section renders only 2 of 3 cards on first paint — Framer Motion + IntersectionObserver race #104

Closed
opened 2026-09-19 01:45:31 +00:00 by Mark · 1 comment
Owner

Bug: "From Image to Stitches in Three Steps" section only renders 2 of 3 cards on first paint

Severity

Medium — first-impression regression on a marketing section the visible copy promises is 3 steps. Affects every landing-page visit until the section's intersection observer fires.

Reproduction

  1. Bring up the dev server on :3000.
  2. Navigate to / (home page).
  3. Scroll to section labeled "How It Works — From Image to Stitches in Three Steps".
  4. Inspect the rendered grid.

Expected: 3 cards (01 Upload, 02 Choose, 03 Download).
Actual: only 2 cards visible (01 Upload, 02 Choose); the 03 Download card is missing.

Evidence

/tmp/section_2.png (visual): captures the section with steps 01 and 02 rendered, the third column empty despite the heading promising "Three Steps".

Vision model on the screenshot:

"The section title promises 'Three Steps' but only two cards (01 and 02) are visible. Step 03 is missing entirely from the right side of the layout. ... The section feels half-rendered — the structure exists but the third step is absent, undermining the 'Three Steps' promise in the heading."

Source

src/pages/Home.tsx:351-366 defines all three steps in the source:

{ num: '01', icon: <UploadCloud … />, title: 'Upload Your Design',     body: … }
{ num: '02', icon: <SlidersHorizontal … />, title: 'Choose Your Settings', body: … }
{ num: '03', icon: <CheckCircle … />, title: 'Download & Stitch',      body: … }

The render loop at src/pages/Home.tsx:393-433:

<div className="grid grid-cols-1 md:grid-cols-3 gap-8 relative">
  {steps.map((step, i) => (
    <motion.div key={step.num}
      initial={{ opacity: 0, y: 30 }}
      animate={inView ? { opacity: 1, y: 0 } : {}}
      transition={{ duration: 0.5, delay: i * 0.12, ease: [0, 0, 0.2, 1] }}>
      …
    </motion.div>
  ))}

inView is a Framer Motion useInView flag driven by the IntersectionObserver on the section ref. Step 3's animation has delay: 0.24s and only starts after the observer fires.

Root cause (best guess)

Framer Motion useInView defaults to once: false and is observed against the section's bounding box. For a 480px-tall section, the third-column card (right edge) sits inside the section's viewport, but the intersection observer fires only when the section enters the viewport — not when each card individually does.

Result: when the user lands on the page (no scrolling yet) and the section is just below the fold, the observer fires with the bottom of the section visible but inView reports partially-true. Step 3 has the longest delay (0.24s) and is the most vulnerable. If the screenshot was taken in the animation window, step 3 is still at opacity: 0, y: 30.

A user who reloads the page with the section partially in view at the moment of first paint will see a 2-card section permanently until they scroll past it and the observer re-fires.

Suggested fix

  1. Use Framer Motion's whileInView with viewport={{ once: true, amount: 0.2 }} so the trigger is per-element and only runs once.
  2. Or remove the entry animation entirely (it's purely cosmetic — keeping card 3 invisible for 240ms is worse UX than no animation).
  3. Or initial-state opacity: 1, y: 0 and animate only the cards that are still off-screen.
  • Other motion-driven sections may share this race. Worth a sweep of all useInView-driven entry animations.
# Bug: "From Image to Stitches in Three Steps" section only renders 2 of 3 cards on first paint ## Severity Medium — first-impression regression on a marketing section the visible copy promises is 3 steps. Affects every landing-page visit until the section's intersection observer fires. ## Reproduction 1. Bring up the dev server on :3000. 2. Navigate to `/` (home page). 3. Scroll to section labeled **"How It Works — From Image to Stitches in Three Steps"**. 4. Inspect the rendered grid. Expected: 3 cards (01 Upload, 02 Choose, 03 Download). Actual: only 2 cards visible (01 Upload, 02 Choose); the 03 Download card is missing. ## Evidence `/tmp/section_2.png` (visual): captures the section with steps 01 and 02 rendered, the third column empty despite the heading promising "Three Steps". Vision model on the screenshot: > "The section title promises 'Three Steps' but only two cards (01 and 02) are visible. Step 03 is missing entirely from the right side of the layout. ... The section feels half-rendered — the structure exists but the third step is absent, undermining the 'Three Steps' promise in the heading." ## Source `src/pages/Home.tsx:351-366` defines all three steps in the source: ```ts { num: '01', icon: <UploadCloud … />, title: 'Upload Your Design', body: … } { num: '02', icon: <SlidersHorizontal … />, title: 'Choose Your Settings', body: … } { num: '03', icon: <CheckCircle … />, title: 'Download & Stitch', body: … } ``` The render loop at `src/pages/Home.tsx:393-433`: ```tsx <div className="grid grid-cols-1 md:grid-cols-3 gap-8 relative"> {steps.map((step, i) => ( <motion.div key={step.num} initial={{ opacity: 0, y: 30 }} animate={inView ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.5, delay: i * 0.12, ease: [0, 0, 0.2, 1] }}> … </motion.div> ))} ``` `inView` is a Framer Motion `useInView` flag driven by the IntersectionObserver on the section ref. Step 3's animation has `delay: 0.24s` and only starts after the observer fires. ## Root cause (best guess) Framer Motion `useInView` defaults to `once: false` and is observed against the section's bounding box. For a 480px-tall section, the third-column card (right edge) sits inside the section's viewport, but the intersection observer fires only when the section enters the viewport — not when each card individually does. Result: when the user lands on the page (no scrolling yet) and the section is *just* below the fold, the observer fires with the bottom of the section visible but `inView` reports partially-true. Step 3 has the longest delay (`0.24s`) and is the most vulnerable. If the screenshot was taken in the animation window, step 3 is still at `opacity: 0, y: 30`. A user who reloads the page with the section partially in view at the moment of first paint will see a 2-card section permanently until they scroll past it and the observer re-fires. ## Suggested fix 1. Use Framer Motion's `whileInView` with `viewport={{ once: true, amount: 0.2 }}` so the trigger is per-element and only runs once. 2. Or remove the entry animation entirely (it's purely cosmetic — keeping card 3 invisible for 240ms is worse UX than no animation). 3. Or initial-state `opacity: 1, y: 0` and animate only the cards that are still off-screen. ## Related - Other motion-driven sections may share this race. Worth a sweep of all `useInView`-driven entry animations.
Author
Owner

Fixed in df02208 (push 81fd94f → df02208 after rebase on top of e15556f).

-              initial={{ opacity: 0, y: 30 }}
-              animate={inView ? { opacity: 1, y: 0 } : {}}
+              initial={{ opacity: 0, y: 30 }}
+              whileInView={{ opacity: 1, y: 0 }}
+              viewport={{ once: true, amount: 0.2 }}

Home.tsx lines 396-407. Switched the per-card motion.div from the shared-section animate={inView ? … : {}} flag to whileInView + viewport={{ once: true, amount: 0.2 }}. The trigger is now per-element (each card animates the first time its own bounding box is 20% visible), so step 3 no longer gets stuck waiting on the section-level observer timing. once: true keeps it one-shot — re-scrolling through the section does not replay the entry animation.

Left the section-level inView in place for the header and connecting-lines motion, which are not affected by this race.

Verified locally: tsc --noEmit clean, eslint src/pages/Home.tsx clean. Push landed on origin/main as df02208.

Fixed in df02208 (push 81fd94f → df02208 after rebase on top of e15556f). ```diff - initial={{ opacity: 0, y: 30 }} - animate={inView ? { opacity: 1, y: 0 } : {}} + initial={{ opacity: 0, y: 30 }} + whileInView={{ opacity: 1, y: 0 }} + viewport={{ once: true, amount: 0.2 }} ``` `Home.tsx` lines 396-407. Switched the per-card `motion.div` from the shared-section `animate={inView ? … : {}}` flag to `whileInView` + `viewport={{ once: true, amount: 0.2 }}`. The trigger is now per-element (each card animates the first time its own bounding box is 20% visible), so step 3 no longer gets stuck waiting on the section-level observer timing. `once: true` keeps it one-shot — re-scrolling through the section does not replay the entry animation. Left the section-level `inView` in place for the header and connecting-lines motion, which are not affected by this race. Verified locally: `tsc --noEmit` clean, `eslint src/pages/Home.tsx` clean. Push landed on `origin/main` as df02208.
Mark closed this issue 2026-09-19 01:50:58 +00:00
Sign in to join this conversation.
No description provided.