Section
Lume Can Hero
A scroll-pinned 3D can rendered with Three.js: arrows spin it to the next or previous flavor — reversing direction for "previous" — while the label swaps mid-turn, the can tilts toward the cursor, and scrolling drives a baked drop-and-flip animation.
Loading can…
Peach + Rose
Move your cursor over it, or click the arrows — open it in a new page for the scroll-driven drop
import LumeCanHero from "@/components/ui/lume-can-hero";
export default function LumeCanHeroDemo({ fullscreen }: { fullscreen?: boolean }) {
if (fullscreen) return <LumeCanHero />;
return (
<div className="w-full min-w-0 space-y-3">
{/*
The hero pins itself over four viewports and scrubs the drop/flip
against window scroll, so that scrub is switched off in the docs
frame — its own `disableScrollAnimations` prop exists for exactly
this. The flavor spin and cursor parallax still run.
*/}
<div className="h-[560px] w-full overflow-hidden rounded-card border border-line">
<LumeCanHero disableScrollAnimations />
</div>
<p className="text-center text-xs text-faint">
Move your cursor over it, or click the arrows — open it in a new page for the scroll-driven drop
</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install three clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/lume-can-hero.tsx "use client"; import { useEffect, useRef, useState } from "react"; import * as THREE from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { cn } from "@/lib/utils"; export interface LumeFlavor { name: string; file: string; /** Hero background for this flavor. Crossfades in as its label swaps on. */ background: string; } const DEFAULT_FLAVORS: LumeFlavor[] = [ { name: "Peach + Rose", file: "/lume/textures/peach_rose.png", background: "#f6ded2" }, { name: "Mango + Chili", file: "/lume/textures/mango_chili.png", background: "#f8dcb0" }, { name: "Blueberry + Mint", file: "/lume/textures/blueberry_mint.png", background: "#d9e6e2" }, { name: "Lemon + Ginger", file: "/lume/textures/lemon_ginger.png", background: "#f5ecc4" }, ]; export interface LumeCanHeroProps { /** Flavors cycled by the arrows, in order. */ flavors?: LumeFlavor[]; /** Path to the baked glTF (geometry + NLA_Spin / NLA_Drop clips). */ modelSrc?: string; /** Seconds the flavor-swap spin takes, regardless of the baked clip's own length. */ spinDuration?: number; /** Skip the window-scroll-driven drop/flip scrub — for embedding somewhere * that scroll position can't sensibly drive it (a docs preview box). The * spin, flavor swap and cursor parallax all still run. */ disableScrollAnimations?: boolean; className?: string; } // The Blender NLA glTF export bakes a clip across the WHOLE scene frame // range, even when the underlying strip only starts partway through — so // the clip's early portion can be a static hold at the resting pose. Scan // each track for the first sample that actually differs from the first, // and treat that as where the "real" animation begins. function getClipActiveStart(clip: THREE.AnimationClip) { let earliest = clip.duration; clip.tracks.forEach((track) => { const itemSize = track.values.length / track.times.length; if (!itemSize || track.times.length < 2) return; const first = track.values.slice(0, itemSize); for (let i = 1; i < track.times.length; i++) { const v = track.values.slice(i * itemSize, (i + 1) * itemSize); let changed = false; for (let k = 0; k < itemSize; k++) { if (Math.abs(v[k] - first[k]) > 1e-4) { changed = true; break; } } if (changed) { earliest = Math.min(earliest, track.times[i - 1]); break; } } }); return earliest < clip.duration ? earliest : 0; } export default function LumeCanHero({ flavors = DEFAULT_FLAVORS, modelSrc = "/lume/lume_can.glb", spinDuration = 0.7, disableScrollAnimations = false, className, }: LumeCanHeroProps) { const pinContainerRef = useRef<HTMLDivElement>(null); const heroRef = useRef<HTMLDivElement>(null); const mountRef = useRef<HTMLDivElement>(null); const [loading, setLoading] = useState(true); const [flavorIndex, setFlavorIndex] = useState(0); const [background, setBackground] = useState(flavors[0]?.background ?? "#f4f1ea"); const nextRef = useRef(() => {}); const prevRef = useRef(() => {}); useEffect(() => { const heroEl = heroRef.current; const mountEl = mountRef.current; const pinEl = pinContainerRef.current; if (!heroEl || !mountEl || !pinEl) return; let disposed = false; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); // Uncapped device pixel ratio — this is a small hero-sized canvas, not a // full-page one, so there's no need to trade resolution for perf here. renderer.setPixelRatio(window.devicePixelRatio || 1); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.1; renderer.outputColorSpace = THREE.SRGBColorSpace; mountEl.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 100); camera.position.set(0, 0.12, 0.55); // No environment map: it's the main source of the glare/highlights // sweeping across the can as it turns. Lighting is flat, diffuse-only // ambient + a soft directional fill instead. const keyLight = new THREE.DirectionalLight(0xffffff, 0.9); keyLight.position.set(1.2, 1.6, 1.0); scene.add(keyLight); scene.add(new THREE.AmbientLight(0xffffff, 0.9)); // The baked clips drive canRoot's own transform directly, so cursor // parallax is layered on this parent group instead of fighting the // mixer for the same properties. const canWrapper = new THREE.Group(); scene.add(canWrapper); const textureLoader = new THREE.TextureLoader(); const flavorTextures: THREE.Texture[] = []; const maxAnisotropy = renderer.capabilities.getMaxAnisotropy(); let mixer: THREE.AnimationMixer | undefined; let labelMaterial: THREE.MeshStandardMaterial | undefined; let spinAction: THREE.AnimationAction | undefined; let dropAction: THREE.AnimationAction | undefined; let spinClipDuration = 1; let dropActiveStart = 0; let dropActiveDuration = 1; let spinPlaying = false; let spinSwapArmed = false; let spinDirection: 1 | -1 = 1; let pendingFlavorIndex = 0; let currentFlavorIndex = 0; // Flavor switching: the spin plays forward for "next" and in reverse // for "prev" (negative timeScale, started from the clip's end), so the // can visibly turns the way the arrow points instead of always // spinning the same direction. Its length is rescaled to `spinDuration` // regardless of how long the baked clip itself runs. The label texture // swaps the instant playback crosses the clip's halfway point, matching // the baked label-snap timing from the Blender source file. function goToFlavor(index: number, direction: 1 | -1) { if (!spinAction || spinPlaying) return; pendingFlavorIndex = (index + flavors.length) % flavors.length; spinDirection = direction; const speed = spinClipDuration / spinDuration; spinAction.reset(); if (direction === 1) { spinAction.time = 0; spinAction.timeScale = speed; } else { spinAction.time = spinClipDuration; spinAction.timeScale = -speed; } spinAction.paused = false; spinAction.play(); spinPlaying = true; spinSwapArmed = true; } nextRef.current = () => goToFlavor(currentFlavorIndex + 1, 1); prevRef.current = () => goToFlavor(currentFlavorIndex - 1, -1); const loader = new GLTFLoader(); loader.load(modelSrc, (gltf) => { if (disposed) return; const canRoot = gltf.scene; canWrapper.add(canRoot); canRoot.traverse((obj) => { const mesh = obj as THREE.Mesh; if (mesh.isMesh) { mesh.frustumCulled = false; const mat = mesh.material as THREE.MeshStandardMaterial; if (mat) { // flat, matte response — no metallic sheen or glossy specular // highlight sweeping across the can as it turns. mat.metalness = 0; mat.roughness = 1; mat.envMapIntensity = 0; mat.needsUpdate = true; // the label material is whichever one has a texture map assigned if (mat.map) labelMaterial = mat; } } }); // frame the camera to the model's bounding box (fit the full height, // then pull back further for headroom + a bit of side breathing room) const box = new THREE.Box3().setFromObject(canRoot); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); const boundingRadius = size.length() * 0.5; const vFov = THREE.MathUtils.degToRad(camera.fov); const fitHeightDistance = boundingRadius / Math.sin(vFov / 2); const fitWidthDistance = fitHeightDistance / camera.aspect; const distance = 1.5 * Math.max(fitHeightDistance, fitWidthDistance); camera.position.set(center.x, center.y + size.y * 0.08, center.z + distance); camera.lookAt(center); camera.near = distance / 100; camera.far = distance * 100; camera.updateProjectionMatrix(); mixer = new THREE.AnimationMixer(canRoot); const spinClip = THREE.AnimationClip.findByName(gltf.animations, "NLA_Spin")!; const dropClip = THREE.AnimationClip.findByName(gltf.animations, "NLA_Drop")!; spinAction = mixer.clipAction(spinClip); spinAction.loop = THREE.LoopOnce; spinAction.clampWhenFinished = true; spinClipDuration = spinClip.duration; dropAction = mixer.clipAction(dropClip); dropAction.loop = THREE.LoopOnce; dropAction.clampWhenFinished = true; dropAction.play(); dropAction.paused = true; // driven manually from scroll dropActiveStart = getClipActiveStart(dropClip); dropActiveDuration = Math.max(dropClip.duration - dropActiveStart, 0.0001); let loadedCount = 0; flavors.forEach((flavor, i) => { textureLoader.load(flavor.file, (tex) => { tex.colorSpace = THREE.SRGBColorSpace; tex.flipY = false; // glTF UVs are already correct without flipping // full anisotropic filtering keeps the label crisp at the // glancing angles a cylindrical can is mostly seen at — this is // the main source of a "blurry" label, not texture resolution. tex.anisotropy = maxAnisotropy; tex.needsUpdate = true; flavorTextures[i] = tex; loadedCount++; if (loadedCount === flavors.length) setLoading(false); }); }); }); // Resize: track the hero element's own box (not the window), so this // stays correct whether it fills the viewport or sits in a bounded // preview box. const resize = () => { const w = heroEl.clientWidth; const h = heroEl.clientHeight; if (!w || !h) return; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); }; resize(); const ro = new ResizeObserver(resize); ro.observe(heroEl); // Scroll-driven drop/flip: map scroll progress through the pin // container's own extra height directly onto the baked animation's // timeline. const getScrollProgress = () => { const extra = pinEl.offsetHeight - window.innerHeight; if (extra <= 0) return 0; const top = pinEl.getBoundingClientRect().top; return Math.min(Math.max(-top / extra, 0), 1); }; const onScroll = () => { if (!dropAction || !mixer) return; const progress = getScrollProgress(); dropAction.time = dropActiveStart + progress * dropActiveDuration; mixer.update(0); }; if (!disableScrollAnimations) { window.addEventListener("scroll", onScroll, { passive: true }); onScroll(); } // Cursor parallax: the can tilts and nudges toward the pointer, // smoothed toward its target every frame in the render loop below. const parallaxTarget = { x: 0, y: 0 }; const parallaxCurrent = { x: 0, y: 0 }; const onPointerMove = (event: PointerEvent) => { const rect = heroEl.getBoundingClientRect(); parallaxTarget.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; parallaxTarget.y = ((event.clientY - rect.top) / rect.height) * 2 - 1; }; const onPointerLeave = () => { parallaxTarget.x = 0; parallaxTarget.y = 0; }; heroEl.addEventListener("pointermove", onPointerMove); heroEl.addEventListener("pointerleave", onPointerLeave); const clock = new THREE.Clock(); let rafId = 0; const animate = () => { rafId = requestAnimationFrame(animate); const dt = clock.getDelta(); // A slow, frame-rate-independent chase toward the pointer target — the // wrapper drifts into place well after the cursor stops, rather than // snapping to it. ~20% of the distance to the target remains after a // full second, versus the ~2% the old fixed 0.06-per-frame factor left. const parallaxEase = 1 - Math.pow(0.2, dt); parallaxCurrent.x += (parallaxTarget.x - parallaxCurrent.x) * parallaxEase; parallaxCurrent.y += (parallaxTarget.y - parallaxCurrent.y) * parallaxEase; canWrapper.rotation.y = parallaxCurrent.x * 0.18; canWrapper.rotation.x = -parallaxCurrent.y * 0.1; canWrapper.position.x = parallaxCurrent.x * 0.015; canWrapper.position.y = -parallaxCurrent.y * 0.01; if (mixer) { mixer.update(dt); if (spinPlaying && spinAction) { const halfway = spinClipDuration * 0.5; const crossedHalf = spinDirection === 1 ? spinAction.time >= halfway : spinAction.time <= halfway; if (spinSwapArmed && crossedHalf) { if (labelMaterial && flavorTextures[pendingFlavorIndex]) { labelMaterial.map = flavorTextures[pendingFlavorIndex]; labelMaterial.needsUpdate = true; } currentFlavorIndex = pendingFlavorIndex; setFlavorIndex(currentFlavorIndex); setBackground(flavors[currentFlavorIndex]?.background ?? flavors[0]?.background ?? "#f4f1ea"); spinSwapArmed = false; } const finished = spinDirection === 1 ? spinAction.time >= spinClipDuration - 0.001 : spinAction.time <= 0.001; if (finished) spinPlaying = false; } } renderer.render(scene, camera); }; animate(); return () => { disposed = true; cancelAnimationFrame(rafId); ro.disconnect(); window.removeEventListener("scroll", onScroll); heroEl.removeEventListener("pointermove", onPointerMove); heroEl.removeEventListener("pointerleave", onPointerLeave); flavorTextures.forEach((tex) => tex?.dispose()); scene.traverse((obj) => { const mesh = obj as THREE.Mesh; if (mesh.geometry) mesh.geometry.dispose(); }); renderer.dispose(); if (mountEl.contains(renderer.domElement)) mountEl.removeChild(renderer.domElement); }; }, [flavors, modelSrc, spinDuration, disableScrollAnimations]); return ( <div ref={pinContainerRef} className={cn("relative w-full", disableScrollAnimations ? "h-dvh" : "h-[400vh]", className)} > <div ref={heroRef} className="sticky top-0 h-dvh w-full overflow-hidden font-sans text-[#1c1c1c] transition-colors duration-[1200ms] ease-out" style={{ backgroundColor: background }} > <div ref={mountRef} className="absolute inset-0 [&>canvas]:!block [&>canvas]:!h-full [&>canvas]:!w-full" /> {loading && ( <div className="absolute inset-0 z-10 flex items-center justify-center text-[0.8rem] tracking-[0.12em] text-[#8a8a8a] uppercase transition-opacity duration-300" style={{ backgroundColor: background }} > Loading can… </div> )} <div className="pointer-events-none absolute inset-0"> <div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-3.5 sm:px-8"> <button type="button" aria-label="Previous flavor" onClick={() => prevRef.current()} className="pointer-events-auto flex h-11 w-11 items-center justify-center rounded-full border border-black/15 bg-white/75 text-[17px] text-[#1c1c1c] backdrop-blur-md transition-transform duration-150 hover:scale-[1.06] hover:bg-white active:scale-[0.94] sm:h-[52px] sm:w-[52px] sm:text-xl" > ← </button> <button type="button" aria-label="Next flavor" onClick={() => nextRef.current()} className="pointer-events-auto flex h-11 w-11 items-center justify-center rounded-full border border-black/15 bg-white/75 text-[17px] text-[#1c1c1c] backdrop-blur-md transition-transform duration-150 hover:scale-[1.06] hover:bg-white active:scale-[0.94] sm:h-[52px] sm:w-[52px] sm:text-xl" > → </button> </div> <div className="pointer-events-none absolute inset-x-0 bottom-14 flex flex-col items-center gap-1.5"> <div className="rounded-full border border-black/10 bg-white/75 px-4.5 py-2 text-[0.75rem] tracking-[0.18em] text-[#1c1c1c] uppercase backdrop-blur-md"> {flavors[flavorIndex]?.name} </div> <div className="flex gap-1.5"> {flavors.map((flavor, i) => ( <span key={flavor.name} className={cn( "h-1.5 w-1.5 rounded-full bg-black/25 transition-all duration-200", i === flavorIndex && "scale-[1.3] bg-[#1c1c1c]", )} /> ))} </div> </div> {!disableScrollAnimations && ( <div className="absolute bottom-[22px] left-1/2 -translate-x-1/2 text-[0.7rem] tracking-[0.16em] text-[#8a8a8a] uppercase opacity-80"> Scroll ↓ </div> )} </div> </div> </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| flavors | { name: string; file: string; background: string }[] | 4 flavors | Cycled by the arrows, in order. `file` is a texture URL applied to the label material; `background` is the hero's background color, crossfaded in as that flavor's label swaps on. |
| modelSrc | string | "/lume/lume_can.glb" | Path to the baked glTF — needs NLA_Spin and NLA_Drop animation clips. |
| spinDuration | number | 0.7 | Seconds the flavor-swap spin takes, independent of the baked clip's own length. |
| disableScrollAnimations | boolean | false | Skips the window-scroll-driven drop/flip scrub, for embedding somewhere scroll position can't drive it. Spin, flavor swap and cursor parallax still run. |
| className | string | — | Merged onto the outer (scroll-height) wrapper. |