I wanted a landing page where the product is the hero: not a photo of a can, but a real 3D can you can grab and turn. The result is Juicy, a one-page site for a made-up juice brand. Four cans sit on a wheel. Scroll, press an arrow key or click a button, and the wheel turns a quarter, the next flavour comes to the front, and the whole page takes on its colours.
It is free and MIT licensed:
- Live demo: https://juicy.aniq-ui.com/
- Source: https://github.com/aniq-ui-pro/nextjs-3d-landing-page
- Template page: https://www.aniq-ui.com/en/templates/3d-landing-page-template
This post walks through the decisions that made it work, with the real code from the repo.
The stack
All versions are pinned exactly in package.json:
- Next.js 15.5 (App Router) and React 19
- three 0.176 with React Three Fiber 9 and drei 10
- GSAP 3.15
- Tailwind CSS 4
- next-intl 4, for English and Arabic
1. The wheel is plain DOM, only the cans are WebGL
My first instinct was one big Three.js scene with four cans on a rotating group. I went the other way: four small canvases, one per can, placed around an ordinary div, and the div is what rotates.
That keeps the 3D work tiny (each canvas renders one model), keeps the layout in Tailwind where it is easy to change, and lets GSAP rotate the wheel like any other element.
Each can gets a slot at 12, 3, 6 or 9 o'clock:
// src/features/carousel/constants/positionConfigs.ts /** One entry per can, placed at 12, 3, 6 and 9 o'clock on the wheel. */ export const positionConfigs: PositionConfig[] = [ { className: "absolute top-0 left-1/2 -translate-x-1/2", transformOrigin: "center center", rotation: [0, Math.PI / 3, 0], }, { className: "absolute top-1/2 right-0 -translate-y-1/2 rotate-90", transformOrigin: "center center", rotation: [0, Math.PI / 3, 0], }, // ...6 o'clock (rotate-180) and 9 o'clock (rotate-270) ]; The wheel itself is a 300vh square, so only the can at 12 o'clock is in view and the other three wait off screen:
// src/features/carousel/components/CarouselWheel.tsx (trimmed) <div ref={wheelRef} dir="ltr" className="relative w-full h-full flex items-center justify-center"> {juiceCans.map((can, index) => ( <div key={can.id} className={`${positionConfigs[index].className} w-1/2 h-1/2 transform-gpu`} style={{ transformOrigin: positionConfigs[index].transformOrigin, willChange: "transform" }} > <CanvasContainer modelPath={can.model} rotation={positionConfigs[index].rotation} isActivePosition={index === activeIndex} /* ... */ /> </div> ))} </div> Note the dir="ltr" on the wheel. More on that in the RTL section.
2. GSAP turns the wheel, one quarter at a time
The rotation lives in one hook. It refuses to start a new turn while one is running, and it only updates the active can when the tween finishes:
// src/features/carousel/hooks/useCarouselRotation.ts (trimmed) const turn = useCallback( (step: 1 | -1) => { if (isAnimating || !isReady) return; setIsAnimating(true); const nextIndex = (activeIndex + step + juiceCans.length) % juiceCans.length; const nextId = juiceCans[nextIndex].id; setNextJuiceId(nextId); setIsTransitioning(true); onCanChangeRef.current?.(nextId); gsap.to(wheelRef.current, { rotation: step === -1 ? "+=90" : "-=90", duration: WHEEL_TURN_DURATION, // 1 second ease: "power2.inOut", onComplete: () => { setActiveIndex(nextIndex); setCurrentJuiceId(nextId); setNextJuiceId(null); setIsAnimating(false); }, }); }, [activeIndex, isAnimating, isReady] ); Relative values ("+=90") mean GSAP never has to know the absolute angle, so the wheel can turn forever in either direction.
3. Mouse wheel, arrow keys and buttons, without double turns
A trackpad fires dozens of wheel events for one swipe. A throttle of 800 ms turns that into one quarter turn. The keyboard handler also steps aside when someone is typing in a field (the page has a small sign-in form):
// src/features/carousel/hooks/useCarouselNavigation.ts (trimmed) const handleWheel = (e: WheelEvent) => { if (isAnimating || !isActive) return; const now = Date.now(); if (now - lastNavigationTime.current < NAVIGATION_THROTTLE_MS) return; // 800 const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; if (delta > 0) onPrev(); else onNext(); lastNavigationTime.current = now; }; window.addEventListener("wheel", handleWheel, { passive: true }); isActive comes from an IntersectionObserver hook, so the wheel only listens while it is on screen.
4. One small canvas per can
Each canvas is set up for a single object: device pixel ratio capped at 1.5, ACES tone mapping, sRGB output, a transparent background so the page colour shows through, and a lower power preference on phones. drei's PresentationControls lets you drag the can and snaps it back when you let go:
// src/features/carousel/components/CanvasContainer.tsx (trimmed) <Canvas shadows dpr={[1, 1.5]} gl={{ toneMapping: THREE.ACESFilmicToneMapping, outputColorSpace: THREE.SRGBColorSpace, antialias: true, alpha: true, powerPreference: isMobile ? "default" : "high-performance", }} camera={{ position: [0, 0, 1], fov: isMobile ? 24 : 22, near: 0.01, far: 100 }} > {/* No fallback inside the canvas: the wheel stays hidden until the active can has loaded, and LoadingIndicator shows progress outside it. A drei <Html> fallback here throws a removeChild error when Suspense swaps it out. */} <Suspense fallback={null}> <PresentationControls global={false} cursor={false} snap={true} speed={isMobile ? 1.5 : 1} polar={[0, Math.PI / 10]} azimuth={[-Math.PI, Math.PI]} > <Can3D modelPath={modelPath} rotate={rotation} /* ... */ /> </PresentationControls> <Environment files={ENVIRONMENT_MAP} resolution={isMobile ? 2 : 8} environmentIntensity={1.2} /> <ambientLight intensity={10} /> <directionalLight intensity={20} position={[5, 5, 5]} /> </Suspense> </Canvas> That comment is a real lesson: a <Html> loading fallback inside the canvas looked tidy, but it crashed when Suspense swapped it out. The loader now lives in the DOM, outside the canvas.
5. Loading the GLB models
The cans are four .glb files in public/assets/3d/can/. All four start downloading as soon as the carousel mounts, so turning the wheel never waits for a model:
// src/features/carousel/hooks/usePreloadJuiceModels.ts /** Starts fetching every can model so turning the wheel never waits. */ export function usePreloadJuiceModels() { useEffect(() => { juiceCans.forEach((can) => useGLTF.preload(can.model)); }, []); } When a model arrives, the hook clones the scene (so each canvas owns its own copy) and pushes the material towards polished metal:
// src/features/carousel/hooks/useCanModel.ts (trimmed) const clonedScene = scene.clone(); clonedScene.traverse((object) => { const mesh = object as THREE.Mesh; if (mesh.isMesh && mesh.material instanceof THREE.MeshStandardMaterial) { mesh.material.metalness = 0.95; mesh.material.roughness = 0.02; mesh.material.alphaTest = 0.1; } }); model.clear(); model.add(clonedScene); Only the can at the front reports "loaded". The page waits for that one, hides the loader, then fades the wheel and the fruits in. Nobody sees a half-built scene.
6. Idle motion belongs in useFrame, not in GSAP
The cans sway gently while you look at them. That runs every frame, so it lives in React Three Fiber's useFrame, driven by delta so the speed is the same at 60 Hz and 120 Hz. On phones the sway is damped to 30%:
// src/features/carousel/hooks/useCanOscillation.ts (trimmed) useFrame((_state, delta) => { const model = modelRef.current; if (!model || !animate) return; timeRef.current += delta * animationSpeed; const time = timeRef.current; const damping = isMobile ? 0.3 : 1; const xOffset = (smoothOscillation(time, 0.3) * 2 - 1) * oscillationAmplitude[0] * damping; // ...same for y and z model.rotation.x = rotate[0] + xOffset; }); My rule of thumb: GSAP for things that happen because the user did something (a turn, a text swap), useFrame for things that never stop.
7. Ship the lighting with the site
drei's <Environment preset="forest" /> is one line, but it downloads the HDRI from a third-party host at runtime. I put the same file (Poly Haven's Forest Slope at 1k, CC0) in public/ instead, so the page makes no outside request and still works if that host is slow:
// src/features/carousel/constants/environment.ts export const ENVIRONMENT_MAP = "/assets/hdri/forest_slope_1k.hdr"; 8. GSAP for the DOM around the 3D
Two DOM effects use GSAP.
The product text fades in when the flavour changes. The hook keeps a handle on the running tween and kills it before starting another, so fast clicking never stacks animations:
// src/features/product-showcase/hooks/useProductInfoTransition.ts (trimmed) tweenRef.current?.kill(); setDisplayedId(juiceId); tweenRef.current = gsap.fromTo( contentRef.current, { opacity: 0, y: 20, force3D: true }, { opacity: 1, y: 0, duration: 0.7, ease: "power2.out", delay: 0.1, overwrite: true, clearProps: "transform" } ); The fruits fly from the centre to their places for the new flavour and back to the centre for the old one. Each fruit's tweens run inside a gsap.context, and ctx.revert() in the cleanup undoes them when the component re-renders or unmounts:
// src/features/floating-fruits/hooks/useFruitAnimation.ts (trimmed) useLayoutEffect(() => { const el = fruitRef.current; if (!el) return; const ctx = gsap.context(() => { if (animationState === "entering" && fromCenter) { gsap.set(el, { left: "50%", top: "50%", opacity: 1, transform: "translate(-50%, -50%) scale(0.2)" }); gsap.to(el, { left: adjustedLeft, top: offsetY, transform: restTransform, duration, ease: "power2.out" }); } // ...visible and exiting states }, fruitRef); return () => ctx.revert(); }, [animationState, fromCenter, adjustedLeft /* ... */]); The page colour needs no GSAP at all. A CSS transition on body does it:
// src/features/landing/hooks/useBodyBackground.ts export function useBodyBackground(color: string) { useEffect(() => { document.body.style.transition = "all 0.6s ease-in-out 0.3s"; document.body.style.backgroundColor = color; }, [color]); } 9. An Arabic version without breaking the 3D
The page ships in English (/en) and Arabic (/ar). next-intl handles the routing, and the locale layout sets lang and dir on <html> on the server, so the Arabic page arrives right to left in the first byte of HTML:
// src/app/[locale]/layout.tsx (trimmed) <html lang={locale} dir={getDirection(locale)} suppressHydrationWarning> <body className={`${cairo.variable} ${anton.variable} antialiased`}> <NextIntlClientProvider>{children}</NextIntlClientProvider> </body> </html> Two things went wrong the first time, and both are worth knowing if you mix RTL with a spatial scene:
- The scene should not mirror. Text and menus flip in Arabic, but a wheel that turns clockwise should keep turning clockwise. That is why the wheel has
dir="ltr": the 3D part keeps its own orientation while everything around it reads right to left. - Absolute positioning can resolve from the other edge. The wheel's container is very wide. With a fixed width and both
leftandrightset, a right-to-left page resolves the box from the right edge, which pushed the wheel off screen. Placing it bytopandleftonly fixed it:
// src/features/carousel/components/JuiceCarousel.tsx (trimmed) {/* Placed by top and left only. With a fixed width and both left and right set, a right-to-left page resolves the box from the right edge, which pushes the wheel off screen. */} <div className="absolute top-0 left-1/2 -translate-x-1/2 w-[300vh] h-[300vh] flex items-center justify-center"> Fonts come from next/font/google: Anton for the big wordmark and Cairo for Arabic text, both free.
Run it
git clone https://github.com/aniq-ui-pro/nextjs-3d-landing-page.git cd nextjs-3d-landing-page corepack enable && yarn install && yarn dev Open http://localhost:3030. With Docker instead:
docker build -t juicy . docker run -p 3030:3030 juicy There is no .env to create: every file the page loads, the models and the lighting included, ships in the repo.
Make it yours
| What | Where |
|---|---|
| Text in both languages | messages/en.json, messages/ar.json |
| The cans (3D models) | src/features/carousel/constants/juiceCans.ts, models in public/assets/3d/can/ |
| Colours per flavour | src/features/product-showcase/constants/themes.ts |
| Lighting | public/assets/hdri/, set in src/features/carousel/constants/environment.ts |
| Fruit images | src/features/floating-fruits/constants/fruitImages.ts |
To swap a flavour, rename its id in src/types/juice.ts, and yarn typecheck lists every file you still need to update.
What I would tell myself before starting
- Keep WebGL for the one thing that needs it. Everything else is easier as DOM.
- Preload every model the user can reach in one click.
- Do not put loaders inside the canvas.
- Self-host anything the scene fetches at runtime.
- In an RTL layout, decide on purpose which parts mirror and which do not.
The repo is MIT, so take it apart, reuse the wheel, or swap the cans for your own product. If you build something with it, I would like to see it.
If you need more than a landing page, the same team makes full Next.js apps (website, dashboard and NestJS API) in English and Arabic: websites from $39, dashboards from $49 and full stack packages at $149, paid once. They are at aniq-ui.com.
