Skip to content
jswnthui

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

Installation

  1. 1. Install the dependencies.

    terminal
    npm install three clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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"
                >
                  &#8592;
                </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"
                >
                  &#8594;
                </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

PropTypeDefaultDescription
flavors{ name: string; file: string; background: string }[]4 flavorsCycled 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.
modelSrcstring"/lume/lume_can.glb"Path to the baked glTF — needs NLA_Spin and NLA_Drop animation clips.
spinDurationnumber0.7Seconds the flavor-swap spin takes, independent of the baked clip's own length.
disableScrollAnimationsbooleanfalseSkips the window-scroll-driven drop/flip scrub, for embedding somewhere scroll position can't drive it. Spin, flavor swap and cursor parallax still run.
classNamestring—Merged onto the outer (scroll-height) wrapper.