Server Components and Interactive UI: Where Animation Actually Belongs
A surprisingly common response to adding animation in a Next.js app is to put "use client" at the top of the component and move on.
The animation works. The warning disappears. The page ships.
Then the boundary spreads.
The hero becomes a Client Component because its heading animates. The section below imports the hero, so that joins the client graph. A layout imports an animation provider. Before long, a mostly static page has acquired a substantial client-side surface because three elements needed to move.
Animation does need the browser sometimes. It does not need the browser in the same way all the time.
That distinction is where the architecture gets interesting.
React Server Components cannot hold interactive state or register browser event handlers. They are rendered outside the browser environment and can be composed with Client Components where interaction is required. React explicitly recommends that composition model rather than treating an entire interface as client-side because one region is interactive.
For animation work, the useful question therefore isn't:
Is this component animated?
It is:
What does the animation need to know at runtime?
The answer usually tells you where it belongs.
Animation does not automatically mean "use client"
Start with the easiest case.
export function Hero() {
return (
<section className="hero">
<h1 className="hero-title">
Interfaces should move for a reason.
</h1>
</section>
)
}
There is nothing preventing that heading from being animated with CSS.
.hero-title {
animation: reveal 600ms cubic-bezier(.22, 1, .36, 1) both;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.hero-title {
animation: none;
}
}
The React component itself does not need state, an effect, window, a pointer position or a DOM reference.
The browser knows how to execute the CSS after the markup arrives.
Turning Hero into a Client Component would not make the animation more legitimate. It would merely move more code across the client boundary without giving React anything useful to do there.
This is the first useful decision rule:
If the movement can be expressed entirely from markup and CSS, the React component can often stay on the server.
Transitions, keyframe entrances, hover states and many decorative loops fit comfortably into this category.
CSS is still client-side behaviour in the literal sense that the browser performs it. It simply does not require client-side React.
That distinction saves a lot of unnecessary "use client" directives.
The client boundary starts where runtime knowledge starts
Now change the interaction.
Suppose the heading should reveal only when it enters the viewport.
The component needs IntersectionObserver.
Or the card should lean toward the pointer.
It needs pointer coordinates.
Or an expanding panel should animate between measured heights.
It may need DOM measurement.
Or a sequence runs through GSAP when a component mounts.
It needs lifecycle code and element references.
These are browser concerns. Current Next.js guidance places state, event handlers, lifecycle logic and browser-only APIs in Client Components.
A useful structure is therefore to keep the content-producing component on the server and introduce a smaller client-side animation boundary around the behaviour that genuinely needs one.
// app/page.tsx
import { AnimatedHero } from "@/components/animated-hero"
export default async function Page() {
const project = await getFeaturedProject()
return (
<main>
<AnimatedHero
title={project.title}
description={project.description}
/>
<ProjectDetails project={project} />
</main>
)
}
// components/animated-hero.tsx
"use client"
import { useEffect, useRef } from "react"
export function AnimatedHero({
title,
description,
}: {
title: string
description: string
}) {
const headingRef = useRef<HTMLHeadingElement>(null)
useEffect(() => {
const heading = headingRef.current
if (!heading) return
const animation = heading.animate(
[
{ opacity: 0, transform: "translateY(16px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{
duration: 600,
easing: "cubic-bezier(.22, 1, .36, 1)",
fill: "both",
}
)
return () => animation.cancel()
}, [])
return (
<section>
<h1 ref={headingRef}>{title}</h1>
<p>{description}</p>
</section>
)
}
The data fetching did not move into the animation component.
The page did not become client-rendered because a heading needed a reference.
Only the part that needs browser lifecycle crossed the boundary.
"use client" is a module boundary, not an animation annotation
This matters because "use client" affects more than the component where the string appears.
In Next.js, declaring a file as a Client Component establishes an entry point into the client module graph. Modules imported beneath that boundary become part of the client bundle as well. Next.js consequently recommends placing Client Component boundaries around specific interactive regions rather than unnecessarily high in the tree.
Consider this:
"use client"
import Hero from "./hero"
import ProjectGrid from "./project-grid"
import Footer from "./footer"
import AnimatedCursor from "./animated-cursor"
export default function HomePage() {
return (
<>
<AnimatedCursor />
<Hero />
<ProjectGrid />
<Footer />
</>
)
}
Only one part actually requires continuous pointer information, but the boundary now sits above the entire page.
A cleaner composition is:
import Hero from "./hero"
import ProjectGrid from "./project-grid"
import Footer from "./footer"
import AnimatedCursor from "./animated-cursor"
export default function HomePage() {
return (
<>
<AnimatedCursor />
<Hero />
<ProjectGrid />
<Footer />
</>
)
}
with:
// animated-cursor.tsx
"use client"
export default function AnimatedCursor() {
// pointer interaction here
}
This sounds almost too obvious when reduced to four lines of code. It becomes less obvious in a real creative frontend where an animation provider, smooth-scroll instance, route transition system and several interaction components begin importing one another.
The boundary should follow behaviour, not visual composition.
A full-screen effect does not necessarily deserve a full-page client boundary.
Server Components can still provide the animated content
There is another architectural mistake hiding in the opposite direction: assuming that once a Client Component wraps something, all of its content also has to become client code.
It does not.
React and Next.js support composing server-rendered content into interactive Client Components. Next.js shows this with patterns such as passing a Server Component through children into a client-side modal.
That is useful for animation shells.
Imagine a horizontally animated gallery where the media metadata comes from the server:
// page.tsx
import { MotionTrack } from "./motion-track"
import { GalleryItems } from "./gallery-items"
export default function Page() {
return (
<MotionTrack>
<GalleryItems />
</MotionTrack>
)
}
MotionTrack can be a Client Component because it measures its container, listens to input or drives transforms.
GalleryItems can remain server-rendered because its job is producing content.
// motion-track.tsx
"use client"
export function MotionTrack({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="motion-track">
{children}
</div>
)
}
The interactive wrapper does not need to take ownership of the data layer merely because it controls how the resulting DOM moves.
This composition becomes particularly valuable for animation-heavy interfaces. The server can remain responsible for fetching, content and structural markup while client islands coordinate only the pieces that need runtime behaviour.
Animation libraries make boundary placement easier to get wrong
Animation libraries encourage broad client boundaries because importing them is convenient.
A developer builds one MotionProvider, places it near the root, and suddenly every page has access to the same animation runtime.
Convenience can hide architecture.
Suppose a landing page contains:
- a CSS reveal on the hero;
- a scroll-triggered feature section;
- an interactive cursor;
- an ordinary pricing table;
- a static footer.
Only two regions may require JavaScript animation at all.
There is little benefit in making the pricing table participate in the client graph simply because it visually sits between animated sections.
Instead:
export default function Page() {
return (
<>
<Hero />
<ScrollStory />
<Pricing />
<PointerExperience />
<Footer />
</>
)
}
ScrollStory and PointerExperience become focused Client Components.
Everything else stays where its requirements naturally place it.
This also makes dependencies easier to reason about. A Three.js scene should not quietly pull a graphics stack into a general layout. A pointer effect should not force unrelated content through the same lifecycle. A GSAP implementation should have a clear mount point and cleanup path rather than existing as ambient page infrastructure simply because several elements move.
Hydration is part of the animation
Client Components still participate in the server-rendered initial experience in Next.js. On the initial load, Next.js can use their output to produce HTML, then hydrate them in the browser so event handlers and client behaviour become active.
That creates a design problem animation demos often hide.
What does the interface look like before the animation code is ready?
Consider an entrance reveal implemented like this:
.title {
opacity: 0;
}
Then JavaScript mounts and animates it to opacity: 1.
If the JavaScript is late, interrupted, disabled or errors before initialization, your design has turned the primary heading into invisible content.
The static state should usually be valid on its own.
One approach is progressive enhancement: render the readable state, then add animation-specific setup once the client runtime is available.
For some interactions, CSS can handle the initial state. For others, the animation library provides mechanisms for controlling initial rendering. Either way, the decision deserves attention because hydration is not merely a React implementation detail when the first visual state depends on it.
A demo can assume initialization happens immediately. A production page has networks.
Scroll animation is mostly a browser problem
Scroll-driven experiences make the boundary especially clear.
A Server Component can generate:
- the heading;
- the article copy;
- images;
- project data;
- section structure.
It cannot know the live viewport dimensions, current scroll position or whether a section has crossed an intersection threshold in the user's browser.
Those behaviours belong in a client layer.
That does not mean the entire storytelling section must be modeled as React state.
If the animation runtime can work through refs and transforms, keep React out of every frame. React can establish the structure while an imperative animation system handles continuous visual updates.
The lifecycle responsibilities still belong in the component:
useEffect(() => {
const observer = new IntersectionObserver(callback)
const element = sectionRef.current
if (element) observer.observe(element)
return () => {
observer.disconnect()
}
}, [])
The same principle applies to pointer listeners, ResizeObserver, animation frames and third-party timelines: setup belongs with cleanup.
The more creative the interaction becomes, the more valuable a small, explicit client island usually becomes. It gives the effect a place where its browser assumptions are visible instead of scattering them across the route.
Route transitions are a different problem again
Developers sometimes treat all motion as though it belongs at component mount.
Route transitions do not.
A route transition cares about navigation state, continuity between screens, potentially exit timing and whatever routing primitives the framework exposes. The relevant client boundary may therefore live higher than it would for an isolated hover effect.
That is fine.
“Keep every Client Component tiny” is not a useful absolute rule.
The better rule is to make the boundary as broad as the behaviour requires and no broader.
A cursor may need one isolated client component.
A carousel may need a client wrapper around its items.
A page-transition system may legitimately need a shared boundary around route content.
Architecture follows the scope of the interaction.
Browser input should influence the implementation
Pointer animation is another place where client-side code is obviously necessary but "use client" is only the first decision.
Once the effect owns pointer events, it also owns the questions that come with them.
What happens on touch?
Does the effect depend on hover as an affordance?
Does it continue running after the pointer stops?
Are listeners removed when the component unmounts?
If requestAnimationFrame drives interpolation, when does the loop stop?
A cursor trail that produces excellent screenshots but runs an unnecessary animation loop indefinitely is still unfinished frontend work.
The same scrutiny applies to WebGL. Once an effect requires canvas dimensions, DPR, pointer uniforms or a render loop, it belongs firmly in browser territory. The important architectural question then shifts from whether it is a Client Component to how much of the surrounding page needs to become one.
Usually, less than the demo suggests.
Reduced motion belongs at the same boundary
prefers-reduced-motion can be handled in CSS for CSS-driven motion:
@media (prefers-reduced-motion: reduce) {
.animated {
animation: none;
transition: none;
}
}
JavaScript-driven sequences need their own response.
A client animation component can read the media query and choose a static or reduced version rather than merely changing duration from 600 milliseconds to 200.
Reduced motion is not synonymous with no motion, and supporting the preference does not settle every accessibility question. Keyboard behaviour, focus, semantics and whether essential information exists outside the animation still require separate judgement.
The useful architectural point is simpler: accessibility behaviour should live with the interaction it modifies.
Passing data across the boundary should stay boring
Once the client island is small, its props tend to become clearer.
<AnimatedProductCard
title={product.title}
image={product.image}
href={product.href}
/>
This is preferable to passing half a server-side application into a client animation component and asking it to reconstruct the page.
Next.js requires props crossing into Client Component entry points to be serializable.
For interaction work, that constraint can be productive.
Send the information the effect needs.
Keep database access, server-only secrets and unrelated application logic on the other side of the boundary.
The animation should rarely need to know how the product record was obtained.
It needs to know what it is animating.
A practical decision tree
When placing an animation in a Server Component architecture, I use roughly this sequence:
Can CSS express the behaviour?
Keep the React component on the server unless something else requires client code.Does the effect require state, event handlers or a browser API?
Create a Client Component.How small can that client boundary reasonably be?
Do not automatically promote the parent page or layout.Can server-rendered content be passed through the interactive wrapper?
Prefer composition to moving data work into the client.What happens before hydration?
The initial HTML should still produce a sensible interface.What owns lifecycle and cleanup?
Observers, listeners, timelines and render loops need explicit teardown.What happens on touch and reduced motion?
The desktop interaction is only one operating condition.
This model scales better than treating "use client" as a fix for animation errors.
Where source-first interaction patterns become useful
This boundary work is also why reusable interaction code is more useful when it arrives as something you can inspect rather than an opaque visual preset.
A polished interaction eventually collides with the application around it: the breakpoint changes, the scroll container is no longer window, the hero lives under a different stacking context, reduced motion needs another state, or an effect that makes sense with a mouse should disappear on touch.
Hyperiux Vault takes a source-first approach to React and Next.js interaction patterns: effects are added to the project as editable source, so the team can inspect and adapt the implementation rather than being limited to a runtime-only abstraction.
That matters particularly around Server Components because the correct client boundary is application-specific. An interaction can provide a useful starting architecture, but your route structure, content source and surrounding component tree still determine where that boundary should sit.
The useful thing to reuse is the interaction logic.
You should still own the architectural decision.
The boundary is part of the design
Server Components do not make animation awkward. They force a useful question that client-only React made easy to ignore.
Which part of this interface genuinely needs to stay alive in the browser?
Sometimes the answer is an entire interactive experience.
Sometimes it is a 40-line wrapper around server-rendered content.
Sometimes the answer is none of the React tree at all because CSS already does the job.
Next.js uses Server Components by default in the App Router and recommends Client Components where interaction or browser APIs require them. Animation architecture works best when it follows the same principle instead of treating movement as permission to move the whole page to the client.
Keep data and structure where the server is useful.
Put runtime behaviour where the browser is required.
And make the boundary follow the interaction rather than the screenshot.
Top comments (2)
The hydration section is the part I'd underline. Starting at opacity: 0 in CSS and waiting for JS to reveal means the heading is invisible if the bundle is late or errors out. Defaulting to the readable state and letting the effect add the pre-reveal class after mount avoids that, and it plays nicer with reduced motion too.
Exactly! The initial state should never depend on JavaScript successfully executing. One important distinction is that adding the pre-reveal class after mount also needs careful handling to avoid a flash or sudden disappearance of already visible content.
The goal is progressive enhancement, content stays accessible by default and animation only takes over when the browser is ready to run it. Reduced motion should follow the same principle.
A polished entrance animation isn't worth much if the content fails to make an entrance at all :)