ehsan.blog
~/blog/how-to-trigger-entrance-animations-when-a-section-is-onscreen — zsh
cat how-to-trigger-entrance-animations-when-a-section-is-onscreen.md

How to trigger entrance animations only when a section is on screen

·4 min read

Entrance animations are only satisfying if they play when you arrive at a section. If everything animates at once on page load, the reveal you spent time building is over before the visitor ever scrolls to it. I wanted each section of my portfolio to run its intro sequence the moment it comes on screen — and re-run if you scroll away and back.

The trick is boringly simple: the parent tracks which section is current, and hands each child a single boolean saying “you’re on screen now.” Here’s how.

One source of truth in the parent

My app is a horizontal slideshow — five-plus panels sitting side by side, navigated by wheel, swipe, or nav dots. The parent keeps one piece of state, current, holding the index of the visible panel.

tsx
// src/App.tsx
const [current, setCurrent] = useState(0)

Every navigation action (wheel, keyboard, clicking a nav dot) just updates current. That single number is the whole story of “where am I.”

Passing isActive down

When I render the sections, each one gets an isActive prop that’s simply current === index:

tsx
// src/App.tsx
<HeroSection isActive={current === 0} />
<AboutSection isActive={current === 1} />
<ExperienceSection isActive={current === 2} />
<SkillsSection isActive={current === 3} />
<ProjectsSection isActive={current === 4} />
<ContactSection isActive={current === 5} />

So exactly one section receives isActive={true} at any time. When current changes, React re-renders and the boolean flips for the two affected sections — the one you left goes false, the one you arrived at goes true.

flowchart LR
  W[wheel / swipe / dot] --> C["setCurrent(index)"]
  C --> P["current === i ?"]
  P -->|true| A[section animates in]
  P -->|false| R[section resets]

Reacting to it inside a section

A section watches isActive in an effect. When it becomes true, kick off the animation timeline; when it’s false, reset back to the start so it’s ready to replay. Here’s the skills section, trimmed:

tsx
// src/components/portfolio/SkillsSection.tsx
export function SkillsSection({ isActive = false }: { isActive?: boolean }) {
  const [animPhase, setAnimPhase] = useState<
    "idle" | "typing" | "bars" | "categories" | "complete"
  >("idle")
  const isMobile = useIsMobile()

  useEffect(() => {
    if (isActive || isMobile) {
      setAnimPhase("typing")   // start the intro sequence
    } else {
      setAnimPhase("idle")     // reset so it can replay next time
    }
  }, [isActive, isMobile])
  // ...
}

Two things worth calling out:

  • The prop has a default (isActive = false), so a section renders safely even if a parent forgets to pass it — important because this app is prerendered and every section must render without crashing.
  • The else branch resets state. Because I reset to idle when the section leaves, scrolling back later runs the whole reveal again instead of showing a finished, static panel.

The hero section uses the same pattern to drive its longer boot sequence, resetting its timeline when it goes off screen:

tsx
// src/components/portfolio/HeroSection.tsx
useEffect(() => {
  if (!isActive) {
    setTimeline(0)   // rewind when we scroll away
    setBootStep(0)
    return
  }
  const timers: ReturnType<typeof setTimeout>[] = []
  timers.push(setTimeout(() => setTimeline(1), T.BOOT_START))
  // ...more timed steps...
  return () => timers.forEach(clearTimeout)
}, [isActive])

Notice the cleanup function clears every timer. If you scroll away mid-sequence, the pending setTimeouts are cancelled — otherwise they’d fire later and animate a panel that’s no longer visible.

Why not IntersectionObserver?

For normal vertical document scroll, IntersectionObserver is the usual tool for “is this on screen.” But my layout doesn’t scroll the document — panels are moved with a CSS translateX, so nothing actually enters or leaves the viewport in the way the observer measures. Since the parent already knows the active index, deriving isActive from current is simpler and exact. Use the observer when the browser owns the scroll; use a state-driven flag when you own it.

Once a section is active, the actual reveals are pure CSS — the same technique behind the animated skill bars and the blur-in image reveal that these panels gate on.

What to remember

  • Keep one current index in the parent; derive isActive={current === i} for each child.
  • Only one section is active at a time, so animations fire on arrival, not on load.
  • In the child, start the animation when isActive turns true and reset when it turns false so it can replay.
  • Always clear timers in the effect cleanup so stale animations don’t fire off screen.
  • Give isActive a default value so every section still renders during prerendering.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.