DEV Community

Jyoti Pathak
Jyoti Pathak

Posted on

Animated Next.js Without Turning the Whole Page into a client component

You add a scroll reveal to the hero, so the component gets "use client".

Then the custom cursor needs pointer coordinates. The feature section needs layout measurements. A route transition needs navigation state. Eventually somebody puts "use client" at the top of page.tsx because the page is animated anyway.

Nothing immediately breaks.

That is what makes the pattern easy to keep.

For animation-heavy Next.js pages, "use client" usually belongs at the smallest practical boundary that genuinely needs state, effects, event handlers, DOM access, browser APIs, or an animation runtime.

The presence of animation is not, by itself, a reason to make the page a Client Component.

"use client" describes a boundary

In the App Router, pages and layouts are Server Components by default.

A file marked with "use client" establishes an entry point into the client-side module graph. Everything imported through that client entry point needs to be considered as part of that client-side architecture.

That is more useful than thinking of "use client" as an "animation mode."

For example, this is usually a suspicious boundary:

// app/page.tsx
"use client"

import Hero from "@/components/Hero"
import Features from "@/components/Features"
import Testimonials from "@/components/Testimonials"
import Footer from "@/components/Footer"

export default function Page() {
  return (
    <>
      <Hero />
      <Features />
      <Testimonials />
      <Footer />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Maybe Hero tracks the pointer.

Maybe everything else is ordinary content.

The page-level directive now makes the route itself the client entry point even though most of the route has no browser dependency.

A more accurate architecture is:

// app/page.tsx
import AnimatedHero from "@/components/AnimatedHero"
import Features from "@/components/Features"
import Testimonials from "@/components/Testimonials"
import Footer from "@/components/Footer"

export default function Page() {
  return (
    <>
      <AnimatedHero />
      <Features />
      <Testimonials />
      <Footer />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Then the component responsible for the browser behaviour establishes its own boundary:

// components/AnimatedHero.tsx
"use client"

export default function AnimatedHero() {
  // Pointer state
  // Effects
  // Browser APIs
  // Animation hooks

  return <section>{/* ... */}</section>
}
Enter fullscreen mode Exit fullscreen mode

Now the architecture says something useful.

The route is server-owned. The interactive hero owns the runtime it actually requires.

What normally requires a Client Component?

Animation is too broad a category to answer this question.

A CSS transition is animation. So is a GSAP ScrollTrigger sequence. So is a continuously rendered WebGL scene.

Their runtime requirements are completely different.

A Client Component is commonly justified when the implementation needs things such as:

  • React state or client-side hooks
  • event handlers
  • window, document, or other browser APIs
  • DOM measurement
  • pointer or touch input
  • scroll listeners
  • observers tied to browser behaviour
  • imperative animation timelines
  • canvas rendering
  • WebGL

Even that list is contextual.

A hover effect implemented entirely with CSS does not need React client state because it moves. A keyframe animation does not suddenly require hydration because the transform is visually elaborate.

The implementation should determine the boundary.

Think in client islands

Consider a product page with:

  • an animated hero
  • ordinary product copy
  • a scroll-controlled feature sequence
  • testimonials
  • a WebGL product scene
  • a normal footer

Making the whole route a Client Component is certainly possible:

// app/product/page.tsx
"use client"

export default function ProductPage() {
  return (
    <>
      <Hero />
      <ProductIntro />
      <FeatureStory />
      <Testimonials />
      <ProductScene />
      <Footer />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

But three interactive regions do not require six regions to share the same runtime boundary.

The page can stay server-owned:

// app/product/page.tsx
import AnimatedHero from "./AnimatedHero"
import ProductIntro from "./ProductIntro"
import FeatureStory from "./FeatureStory"
import Testimonials from "./Testimonials"
import ProductScene from "./ProductScene"
import Footer from "./Footer"

export default async function ProductPage() {
  const product = await getProduct()

  return (
    <>
      <AnimatedHero title={product.title} />
      <ProductIntro product={product} />
      <FeatureStory />
      <Testimonials quotes={product.quotes} />
      <ProductScene />
      <Footer />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Then the browser-dependent components establish boundaries where they are needed:

// AnimatedHero.tsx
"use client"
Enter fullscreen mode Exit fullscreen mode
// FeatureStory.tsx
"use client"
Enter fullscreen mode Exit fullscreen mode
// ProductScene.tsx
"use client"
Enter fullscreen mode Exit fullscreen mode

This also makes the actual cost of each interaction easier to reason about.

The GSAP lifecycle belongs to the feature sequence. The WebGL loading strategy belongs to the scene. Pointer behaviour can change or disappear on coarse-pointer devices without turning the footer into part of the same architectural unit.

The rendered tree is not the module graph

A lot of confusion disappears once these two structures are separated mentally.

Your module graph might resemble:

page.tsx
├── ProductIntro
├── Testimonials
├── AnimatedHero
│   ├── "use client"
│   └── animation dependencies
└── ProductScene
    ├── "use client"
    └── WebGL dependencies
Enter fullscreen mode Exit fullscreen mode

The user still sees one page:

<Page>
  <AnimatedHero />
  <ProductIntro />
  <FeatureStory />
  <Testimonials />
  <ProductScene />
  <Footer />
</Page>
Enter fullscreen mode Exit fullscreen mode

Visual nesting does not require the runtime architecture to become one giant client graph.

This becomes especially useful when an interactive component needs to surround server-rendered content.

Pass data into the interaction

Splitting a page into client islands does not mean data fetching needs to move into those islands.

A Server Component can fetch the data:

// app/page.tsx
import AnimatedHeadline from "@/components/AnimatedHeadline"

export default async function Page() {
  const campaign = await getCampaign()

  return (
    <AnimatedHeadline
      eyebrow={campaign.eyebrow}
      title={campaign.title}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

The Client Component receives the serializable values it actually needs:

// components/AnimatedHeadline.tsx
"use client"

type Props = {
  eyebrow: string
  title: string
}

export default function AnimatedHeadline({
  eyebrow,
  title,
}: Props) {
  return (
    <header>
      <p>{eyebrow}</p>
      <h1>{title}</h1>
    </header>
  )
}
Enter fullscreen mode Exit fullscreen mode

The animation owns interaction behaviour without inheriting responsibility for unrelated server concerns.

This is also a cleaner debugging surface. Its inputs are obvious, its browser dependencies are local, and lifecycle cleanup has a much smaller place to hide.

A Client Component can wrap server-rendered content

Suppose you want an animated reveal wrapper around content that otherwise has no reason to become client-owned.

The Server Component can perform the composition:

// app/page.tsx
import RevealShell from "@/components/RevealShell"
import ProductDetails from "@/components/ProductDetails"

export default function Page() {
  return (
    <RevealShell>
      <ProductDetails />
    </RevealShell>
  )
}
Enter fullscreen mode Exit fullscreen mode

The wrapper itself remains client-side:

// components/RevealShell.tsx
"use client"

export default function RevealShell({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="reveal-shell">
      {children}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

ProductDetails does not need "use client" merely because it appears visually inside RevealShell.

The important architectural detail is that RevealShell is not importing ProductDetails. The server parent composes the tree and supplies the content through children.

This pattern works well for things like reveal shells, interactive panels, disclosure systems, transition containers, and layout effects around otherwise server-owned content.

Motion, GSAP, and WebGL should not get identical treatment

Calling all three "animation" hides the useful differences.

Motion: inspect the API you are using

A Motion component that relies on interactive client behaviour can live inside a focused Client Component:

"use client"

import { motion } from "motion/react"

export function Card() {
  return (
    <motion.div whileHover={{ scale: 1.03 }}>
      Hover me
    </motion.div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Motion also provides motion/react-client for React Server Component environments:

import * as motion from "motion/react-client"

export function HeroTitle() {
  return (
    <motion.h1
      initial={{ opacity: 0, y: 16 }}
      animate={{ opacity: 1, y: 0 }}
    >
      Build the boundary around the behavior
    </motion.h1>
  )
}
Enter fullscreen mode Exit fullscreen mode

This does not make every Motion API server-compatible.

Once the interaction depends on client hooks, gesture state, AnimatePresence, browser state, or similar behaviour, a Client Component boundary is still appropriate.

The presence of the motion package alone is not enough information to decide where "use client" belongs.

GSAP: keep the lifecycle with the sequence

Imperative GSAP work commonly interacts with DOM elements, layout, scroll state, and browser timing.

That makes a focused component a natural place for the runtime:

"use client"

import { useRef } from "react"
import { gsap } from "gsap"
import { useGSAP } from "@gsap/react"

gsap.registerPlugin(useGSAP)

export function FeatureSequence() {
  const scope = useRef<HTMLDivElement>(null)

  useGSAP(
    () => {
      gsap.from(".feature-card", {
        opacity: 0,
        y: 40,
        stagger: 0.12,
      })
    },
    { scope }
  )

  return (
    <div ref={scope}>
      <article className="feature-card">...</article>
      <article className="feature-card">...</article>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Putting GSAP in a smaller component does not magically make the animation cheap.

What it does is keep selectors, timeline ownership, setup, and cleanup attached to the feature responsible for them.

An imperative scroll sequence should not become infrastructure for an otherwise server-owned route just because it occupies one section of the page.

WebGL: isolate the subsystem

A Three.js or React Three Fiber scene has a different shape again.

A production scene may involve:

  • a canvas
  • a render loop
  • DPR decisions
  • shaders
  • textures
  • postprocessing
  • pointer input
  • resize handling
  • visibility detection
  • offscreen pausing

That usually deserves an obvious browser-side boundary.

The heading next to the canvas does not need WebGL. Neither does the body copy. Neither does the CTA.

A decorative canvas can be an isolated runtime inside a server-rendered hero instead of becoming the architecture of the entire hero.

ssr: false solves a different problem

A component requiring client execution and a component that should skip prerendering are not necessarily the same thing.

It is tempting to treat this as the standard solution for animation:

dynamic(() => import("./AnimatedThing"), {
  ssr: false,
})
Enter fullscreen mode Exit fullscreen mode

But most animated components do not need SSR disabled.

For browser-heavy code that genuinely should only load on the client, a focused wrapper makes the decision explicit:

// components/ProductSceneClient.tsx
"use client"

import dynamic from "next/dynamic"

const ProductScene = dynamic(
  () => import("./ProductScene"),
  {
    ssr: false,
    loading: () => (
      <div aria-hidden="true">
        Loading visual...
      </div>
    ),
  }
)

export default function ProductSceneClient() {
  return <ProductScene />
}
Enter fullscreen mode Exit fullscreen mode

The server-owned page can then import that wrapper:

// app/product/page.tsx
import ProductSceneClient from "@/components/ProductSceneClient"

export default function ProductPage() {
  return (
    <main>
      <h1>Product</h1>
      <ProductSceneClient />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

With the App Router, ssr: false for next/dynamic belongs inside a Client Component rather than being used directly from a Server Component.

This arrangement makes all four decisions visible:

  1. The page remains server-owned.
  2. The wrapper marks the client boundary.
  3. The browser-only implementation is dynamically loaded there.
  4. Prerendering is disabled specifically for the code that does not benefit from it.

Doing this for a simple hover interaction would be unnecessary ceremony.

For a substantial WebGL subsystem, it can be a sensible architecture.

Small client boundaries are not a performance cheat code

There is an attractive oversimplification here:

Move "use client" lower and the page becomes fast.

No.

A narrower boundary can keep unrelated modules out of the client-side graph. It cannot rescue expensive code inside that boundary.

A neatly isolated WebGL component can still render too much.

A focused scroll sequence can still repeatedly measure layout.

A cursor trail can still produce unnecessary work on every pointer event.

An effect can still ignore prefers-reduced-motion, keep listeners alive after they are useful, update continuously while offscreen, or have no sensible interaction on touch hardware.

The architecture gives you a cleaner place to solve those problems. It does not solve them automatically.

For production animation, I would inspect at least these questions:

  • How much JavaScript does this interaction add?
  • Are event listeners, observers, and timelines cleaned up?
  • Does work continue while the effect is offscreen?
  • What happens on touch and coarse-pointer devices?
  • Is layout being measured repeatedly?
  • Does the render loop actually need to run continuously?
  • What happens when the user requests reduced motion?
  • How does it behave on a mid-range phone?
  • Does meaningful content remain usable without the effect?
  • Are focus, keyboard behaviour, and semantics still correct?

Profile the production build.

A beautiful component tree is not a benchmark.

Source access makes this easier to inspect

This is also why editable interaction source can be useful in animation-heavy Next.js projects.

A source-first library lets the browser dependency remain visible instead of hiding the implementation behind a fixed abstraction.

For example, Hyperiux Vault provides interaction patterns for React and Next.js as editable source files. Different effects can use CSS, Motion, GSAP, canvas, Three.js, React Three Fiber, or other browser APIs depending on the implementation.

The useful question is therefore not:

"Is Vault client-side?"

It is:

"What does this particular effect require?"

If a scroll interaction performs browser measurement and owns an imperative timeline, the source can contain that client boundary.

If a WebGL scene owns a canvas and render loop, you can isolate it and adjust how it loads.

If a lighter effect has no equivalent requirement, it does not need to inherit the architecture of the heavier component next to it.

Source access becomes especially relevant once the demo meets a real application. You may need to change breakpoint behaviour, touch fallbacks, listeners, observers, cleanup, dependency scope, DOM structure, reduced-motion handling, stacking context, or offscreen behaviour.

Those decisions live in implementation code, not in an animation adjective.

Vault's installation documentation covers its current Next.js setup if you want to inspect that source-first workflow.

A practical test before adding "use client"

When a page contains several animated regions, I find this sequence more useful than asking whether the page is "interactive."

1. Name the browser dependency

Be specific.

"Animation" tells you almost nothing.

"Pointer coordinates," "layout measurement," "useEffect," "ScrollTrigger," "window," "canvas," and "WebGL" tell you considerably more.

2. Put the boundary near that dependency

If the hero is the only component tracking the pointer, let the hero own the boundary.

If one feature story contains a GSAP timeline, keep that timeline there.

Do not split components merely to chase the smallest possible file. The goal is for runtime ownership to match the part of the interface creating the requirement.

3. Leave server-owned work outside when it naturally belongs there

Product data, editorial copy, testimonials, metadata, and ordinary layout do not need to inherit the runtime requirements of an adjacent canvas.

Fetch and compose on the server where appropriate. Pass serializable data into the interaction.

If a client-side wrapper needs server-rendered content inside it, compose through children.

4. Use the lightest runtime that actually matches the interaction

Sometimes CSS is enough.

Sometimes Motion is the cleanest fit.

Sometimes the interaction genuinely needs GSAP's imperative timeline model.

Sometimes a WebGL scene deserves its own browser-only loading strategy.

Visual ambition does not tell you which one is correct.

5. Test the behaviour, not the component diagram

Check the production build on real devices.

Test touch.

Test reduced motion.

Test keyboard navigation.

Check cleanup.

Look for offscreen work.

Inspect loading states.

Throttle the device or try hardware that is less forgiving than your development machine.

Several focused Client Components are not necessarily evidence of a fragmented application. They may simply describe the application accurately.

The server owns the parts that do not need browser execution. The browser owns the interactions that do.

That is a much more useful role for "use client" than putting it at the top of a route because something somewhere happens to move.

Top comments (0)