react-countup & react-intersection-observer not animating the count in Production Build
Unanswered
Egyptian Mau posted this in #help-forum
Egyptian MauOP
Hello,
react-countup and react-intersection-observer is working perfectly fine in the dev env npm run dev but I am building the app for the Production Build using npm run build and then startingthe Prod Server using npm run start then unfortunately the App is not running the count animation at all, even using "use client"; and also the import 'client-only', I am sharing the code snippets for now for overview of the issue and if required I can make the repository public from private on GitHub.{
"name": "novareel-studios",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome check",
"format": "biome format --write"
},
"dependencies": {
"@base-ui/react": "^1.1.0",
"class-variance-authority": "^0.7.1",
"client-only": "^0.0.1",
"clsx": "^2.1.1",
"formik": "^2.4.9",
"framer-motion": "^12.27.0",
"lenis": "^1.3.17",
"lucide-react": "^0.562.0",
"next": "16.1.3",
"react": "19.2.3",
"react-countup": "^6.5.3",
"react-dom": "19.2.3",
"react-intersection-observer": "^10.0.2",
"react-lite-youtube-embed": "^3.3.3",
"server-only": "^0.0.1",
"shadcn": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0",
"yup": "^1.7.1"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}30 Replies
Egyptian MauOP
below is the
/src/apps/layout.tsximport type { Metadata, Viewport } from "next"
import { Nunito_Sans } from "next/font/google"
import { Toaster } from "sonner"
import TopProgressBar from "@/components/top-progress-bar"
import { APP_INFO } from "@/constants"
import ClientLayoutWrapper from "@/custom-layout/client-layout-wrapper"
import "./globals.css"
import type { ReactNode } from "react"
const nunitoSans = Nunito_Sans({ variable: "--font-nunito-sans", display: "swap", preload: true, subsets: ["latin"] })
export const viewport: Viewport = {
themeColor: "#000000",
width: "device-width",
initialScale: 1,
maximumScale: 5,
userScalable: true,
}
export const metadata: Metadata = {
title: `${APP_INFO.APP_NAME} | Cinematic Storytelling & Film Production`,
description: "Generated by create next app",
}
export default function RootLayout({
children,
}: Readonly<{
children: ReactNode
}>) {
return (
<html lang="en" className={nunitoSans.variable}>
<body className="bg-zinc-950 text-zinc-100 selection:bg-amber-300/20 selection:text-amber-200 heritage-motif">
<TopProgressBar />
<ClientLayoutWrapper>
{children}
{/* Toast Notifications */}
<Toaster
position="bottom-right"
expand={false}
richColors
closeButton
duration={3000}
toastOptions={{
style: {
fontFamily: nunitoSans.className,
},
}}
/>
</ClientLayoutWrapper>
</body>
</html>
)
}below is the
/src/apps/page.tsx:import type { Metadata } from "next"
export default function Page() {
return (
<>
<section className="relative overflow-hidden">
<HomeHeroSection />
<PartnerStrip />
</section>
<FeaturedFilmography />
<section className="relative">
<HomeTeamSection />
<HomeNewsroomBlog />
<HomeNewsletter />
</section>
</>
)
}below is the
/@/components/page-components/home-page/hero-section.tsx:"use client"
import "client-only"
import Link from "next/link"
import type { FC } from "react"
import EasedCountUp from "@/components/library/eased-count-up"
import { MAIN_PARENT_LAYOUT } from "@/ui"
import WatchTrialBtnModal from "./watch-trial-btn-modal"
const HomeHeroSection: FC = () => {
return (
<>
<div className={MAIN_PARENT_LAYOUT}>
<div className="grain relative">
<div className="pt-24 pb-14 sm:pt-28 sm:pb-16">
<div className="mt-8 grid gap-10 lg:grid-cols-12 lg:items-end">
<div className="lg:col-span-7">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-zinc-200">
<dl className="mt-10 grid max-w-2xl grid-cols-3 gap-4">
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">
<dt className="text-xs text-zinc-300">Films Produced</dt>
<dd className="mt-1 text-2xl font-extrabold" data-counter="28">
<EasedCountUp end={28} />
</dd>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">
<dt className="text-xs text-zinc-300">Awards</dt>
<dd className="mt-1 text-2xl font-extrabold" data-counter="54">
<EasedCountUp end={54} />
</dd>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">
<dt className="text-xs text-zinc-300">Languages</dt>
<dd className="mt-1 text-2xl font-extrabold" data-counter="6">
<EasedCountUp end={6} />
</dd>
</div>
</dl>
</div>and below is the
eased-count-up.tsx:"use client"
import "client-only"
import { type FC, useEffect, useId, useMemo } from "react"
import { useCountUp } from "react-countup"
import { useInView } from "react-intersection-observer"
import { formatCompact } from "@/utils/format-compact"
export interface EasedCountUpProps {
end: number
/** seconds (react-countup) */
duration?: number
/** IntersectionObserver threshold */
threshold?: number
className?: string
}
const EasedCountUp: FC<EasedCountUpProps> = (props) => {
const { end, className = "", duration = 0.9, threshold = 0.4 } = props
// useId() contains ":" which isn't great for DOM ids -> sanitize
const reactId = useId()
const id = useMemo(() => `countup-${reactId.replace(/:/g, "")}`, [reactId])
const { ref: inViewRef, inView } = useInView({ triggerOnce: true, threshold })
const { start, reset } = useCountUp({
ref: id,
start: 0,
end,
duration: duration,
startOnMount: false,
// CountUp.js signature: easingFn(t, b, c, d)
// t = elapsed ms, b = startVal, c = end-start, d = duration ms
easingFn: (t, b, c, d) => {
const n = d ? Math.min(1, t / d) : 1 // normalize 0..1
return b + c * (0.12 + 0.88 * n) // EXACT same curve
},
formattingFn: formatCompact,
})
useEffect(() => {
if (!inView) return
reset()
start()
}, [inView, reset, start])
return (
<span id={id} ref={inViewRef} className={className}>
0
</span>
)
}
export default EasedCountUpand this is my
next.config.ts:import type { NextConfig } from "next"
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
reactStrictMode: true,
experimental: {
inlineCss: true,
},
}
export default nextConfigbut during the
npm run build and then npm run start at this point only, my this EasedCountUp is not working at all, it always stays 0@Egyptian Mau but during the `npm run build` and then `npm run start` at this point only, my this `EasedCountUp` is not working at all, it always stays 0
Poodle
so i actually think this is a react compiler issue not an SSR thing. you have reactCompiler: true in your next config right?
Basically react-countup uses countup.js under the hood which grabs the element by id and directly changes the textContent in the dom. the react compiler doesnt like that because it assumes react owns the dom, so in prod it just
overwrites whatever countup.js did and your number stays at 0. dev mode is more chill with it which is why it works there
try throwing "use no memo" as the first line in your eased-count-up.tsx, before the "use client" line. that just tells the compiler to skip that one
file
lmk if that fixes it
Basically react-countup uses countup.js under the hood which grabs the element by id and directly changes the textContent in the dom. the react compiler doesnt like that because it assumes react owns the dom, so in prod it just
overwrites whatever countup.js did and your number stays at 0. dev mode is more chill with it which is why it works there
try throwing "use no memo" as the first line in your eased-count-up.tsx, before the "use client" line. that just tells the compiler to skip that one
file
tsx
"use no memo"
"use client"
import "client-only"
// rest of your code stays the same
lmk if that fixes it
@Poodle so i actually think this is a react compiler issue not an SSR thing. you have reactCompiler: true in your next config right?
Basically react-countup uses countup.js under the hood which grabs the element by id and directly changes the textContent in the dom. the react compiler doesnt like that because it assumes react owns the dom, so in prod it just
overwrites whatever countup.js did and your number stays at 0. dev mode is more chill with it which is why it works there
try throwing "use no memo" as the first line in your eased-count-up.tsx, before the "use client" line. that just tells the compiler to skip that one
file
tsx
"use no memo"
"use client"
import "client-only"
// rest of your code stays the same
lmk if that fixes it
Egyptian MauOP
unfortunately this isn't working as well...
Furthermore, I ried
Furthermore, I ried
reactCompiler:false even after that it's not working@Egyptian Mau unfortunately this isn't working as well...
Furthermore, I ried `reactCompiler:false` even after that it's not working
Poodle
hmm, give me a sec to think more about it.
@Egyptian Mau unfortunately this isn't working as well...
Furthermore, I ried `reactCompiler:false` even after that it's not working
Poodle
Maybe this is an SSR hydration issue. react-countup uses document.getElementById internally which doesn't exist during server render, and after hydration it never reinitializes properly.
You can try dynamically importing EasedCountUp with ssr disabled.
In hero-section.tsx replace your current import with this:
import dynamic from "next/dynamic"
const EasedCountUp = dynamic(
() => import("@/components/library/eased-count-up"),
{
ssr: false,
loading: () => <span>0</span>,
}
)
Try removing the old static import, the rest can stay the same. This forces it to only mount on the client so countup.js can actually find the dom element.
You can try dynamically importing EasedCountUp with ssr disabled.
In hero-section.tsx replace your current import with this:
import dynamic from "next/dynamic"
const EasedCountUp = dynamic(
() => import("@/components/library/eased-count-up"),
{
ssr: false,
loading: () => <span>0</span>,
}
)
Try removing the old static import, the rest can stay the same. This forces it to only mount on the client so countup.js can actually find the dom element.
@Poodle Maybe this is an SSR hydration issue. react-countup uses document.getElementById internally which doesn't exist during server render, and after hydration it never reinitializes properly.
You can try dynamically importing EasedCountUp with ssr disabled.
In hero-section.tsx replace your current import with this:
import dynamic from "next/dynamic"
const EasedCountUp = dynamic(
() => import("@/components/library/eased-count-up"),
{
ssr: false,
loading: () => <span>0</span>,
}
)
Try removing the old static import, the rest can stay the same. This forces it to only mount on the client so countup.js can actually find the dom element.
Egyptian MauOP
tried this too, but unortunately this is also not working...
@Egyptian Mau tried this too, but unortunately this is also not working...
Poodle
ufff you are making it hard on me man lol.
@Poodle ufff you are making it hard on me man lol.
Egyptian MauOP
lol, but I am just saying the truth and result after I am trying what you've shared!.. and Sorry to make it hard on you
@Egyptian Mau lol, but I am just saying the truth and result after I am trying what you've shared!.. and Sorry to make it hard on you
Poodle
all good haha, let me see what other thing I can think of.
@Poodle all good haha, let me see what other thing I can think of.
Egyptian MauOP
Sure
@Egyptian Mau Sure
Poodle
what if you ditch the hook approach entirely and use the CountUp component instead. This avoids the whole string ID / getElementById problem.
@Poodle what if you ditch the hook approach entirely and use the CountUp component instead. This avoids the whole string ID / getElementById problem.
Egyptian MauOP
you mean instead of
useCountUp I should use <CountUp />?@Egyptian Mau you mean instead of `useCountUp` I should use `<CountUp />`?
Poodle
The problem might be useCountUp with the string ID approach - it uses document.getElementById internally and that's just not playing nice with React 19 prod builds.
Try rewriting EasedCountUp to use the CountUp component instead of the hook, this skips the whole getElementById issue.
Try rewriting EasedCountUp to use the CountUp component instead of the hook, this skips the whole getElementById issue.
Poodle
No useId, no getElementById - the CountUp component handles its own DOM ref internally.
lmk if this one works @Egyptian Mau
"use client"
import "client-only"
import type { FC } from "react"
import CountUp from "react-countup"
import { useInView } from "react-intersection-observer"
import { formatCompact } from "@/utils/format-compact"
export interface EasedCountUpProps {
end: number
duration?: number
threshold?: number
className?: string
}
const EasedCountUp: FC<EasedCountUpProps> = (props) => {
const { end, className = "", duration = 0.9, threshold = 0.4 } = props
const { ref, inView } = useInView({ triggerOnce: true, threshold })
return (
<span ref={ref} className={className}>
{inView ? (
<CountUp
end={end}
duration={duration}
formattingFn={formatCompact}
/>
) : (
"0"
)}
</span>
)
}
export default EasedCountUp lmk if this one works @Egyptian Mau
Egyptian MauOP
"use client"
import "client-only"
import type { FC } from "react"
import CountUp from "react-countup"
// import { useInView } from "react-intersection-observer"
import { formatCompact } from "@/utils/format-compact"
export interface EasedCountUpProps {
end: number
/** seconds (react-countup) */
duration?: number
/** IntersectionObserver threshold */
threshold?: number
className?: string
}
const EasedCountUp: FC<EasedCountUpProps> = (props) => {
const { end, className = "", duration = 2.75, threshold = 0.4 } = props
// const { ref: inViewRef, inView } = useInView({ triggerOnce: true, threshold })
return (
<CountUp
start={0}
end={end}
duration={duration}
startOnMount={false}
enableScrollSpy
useEasing
formattingFn={formatCompact}
easingFn={(t, b, c, d) => {
const n = d ? Math.min(1, t / d) : 1 // normalize 0..1
return b + c * (0.12 + 0.88 * n) // EXACT same curve
}}
className={className}
onEnd={() => console.log("Ended! 👏")}
onStart={() => console.log("Started! 💨")}
>
{({ countUpRef }) => (
<div>
<span ref={countUpRef} />
</div>
)}
</CountUp>
)
}
export default EasedCountUpI tried this @Poodle - but unfortunately this didn't work at all
on top of that no
onEnd={} & onStart={} is being consoled in the browser or anywhereeven I tried:
this also didn't worked
"use client"
import "client-only"
import type { FC } from "react"
import CountUp from "react-countup"
// import { useInView } from "react-intersection-observer"
import { formatCompact } from "@/utils/format-compact"
export interface EasedCountUpProps {
end: number
/** seconds (react-countup) */
duration?: number
/** IntersectionObserver threshold */
threshold?: number
className?: string
}
const EasedCountUp: FC<EasedCountUpProps> = (props) => {
const { end, className = "", duration = 2.75, threshold = 0.4 } = props
// const { ref: inViewRef, inView } = useInView({ triggerOnce: true, threshold })
return (
<CountUp
start={0}
end={end}
duration={duration}
startOnMount={false}
enableScrollSpy
useEasing
formattingFn={formatCompact}
easingFn={(t, b, c, d) => {
const n = d ? Math.min(1, t / d) : 1 // normalize 0..1
return b + c * (0.12 + 0.88 * n) // EXACT same curve
}}
className={className}
onEnd={() => console.log("Ended! 👏")}
onStart={() => console.log("Started! 💨")}
/>
)
}
export default EasedCountUpthis also didn't worked
@Egyptian Mau this also not working
Poodle
So onStart and onEnd not even logging means the CountUp component isn't firing at all. react-countup 6.5.3 just isn't compatible with React 19 - the internal ref handling and scroll spy are broken with it.
Honestly easiest fix is to drop react-countup and write a quick custom one. It's like 20 lines and works with React 19 no issues.
Honestly easiest fix is to drop react-countup and write a quick custom one. It's like 20 lines and works with React 19 no issues.
@Poodle So onStart and onEnd not even logging means the CountUp component isn't firing at all. react-countup 6.5.3 just isn't compatible with React 19 - the internal ref handling and scroll spy are broken with it.
Honestly easiest fix is to drop react-countup and write a quick custom one. It's like 20 lines and works with React 19 no issues.
Egyptian MauOP
honestly speaking, I am not just struggling with this
CountUp but I've Lenis & Framer Motion and that too not working at all in Production Build -- and all this works perfectly fine in dev serve@Egyptian Mau honestly speaking, I am not just struggling with this `CountUp` but I've `Lenis` & `Framer Motion` and that too not working at all in Production Build -- and all this works perfectly fine in dev serve
Poodle
Wait if Lenis and Framer Motion are also broken then this has nothing to do with CountUp. Your whole client hydration is broken in prod - the HTML renders but the JS never takes over. Can you share the repo? No way to debug this without seeing the full setup.
@Poodle Wait if Lenis and Framer Motion are also broken then this has nothing to do with CountUp. Your whole client hydration is broken in prod - the HTML renders but the JS never takes over. Can you share the repo? No way to debug this without seeing the full setup.
Egyptian MauOP
but I am using
and sharing Repo, let me change the visibility to
"use client" wherever it's required..and sharing Repo, let me change the visibility to
publicEgyptian MauOP
finally found the issue, which was causing the problem
Egyptian MauOP
it was due to Hyderation issue from the
lazy() as I was having lazy() but not using the Suspense which was causing the issue, and now the issue is fixed