Skip to content

Scroll and Mouse Interaction

A Three.js guide for coding agents. Also covers scroll based animation, scroll synced camera, three.js background of html page, transparent canvas, alpha renderer, parallax effect, and 20 more.

Show all 26 aliases

scroll based animation, scroll synced camera, three.js background of html page, transparent canvas, alpha renderer, parallax effect, camera follows scroll, lerp camera, easing camera movement, delta time smoothing, gsap scroll trigger three.js, section change animation, raycaster, raycasting, mouse picking, hover 3d object, click on mesh, mouseenter mouseleave webgl, normalized device coordinates, setFromCamera, intersectObjects, raycast gltf model, detect object under cursor, camera group parallax, how do I make objects react to scroll, how do I detect a click on a 3d object

Transparent canvas over an HTML page

  • To use Three.js as the background of a normal scrolling page, make the renderer clear color transparent and let the page background show through: new THREE.WebGLRenderer({ canvas, alpha: true }). The clear alpha defaults to 0 once alpha: true is set, tweak it with renderer.setClearAlpha(value) if needed.
  • Set the page background in CSS (html { background: #1e1a20 }) rather than matching clearColor. This also fixes elastic overscroll: when the page rubber-bands past its limit, the CSS background shows instead of a white flash.
  • Re-enable scrolling by removing any overflow: hidden on html, body.
  • Remove OrbitControls, the scroll drives the camera instead.

Section-based object placement

  • Vertical field of view is the invariant: objects placed at the top and bottom of the frustum stay there on resize. So spacing objects on y by a fixed distance maps cleanly to full-viewport-height sections.
  • One object per section, separated by a constant:
const objectsDistance = 4
mesh1.position.y = - objectsDistance * 0
mesh2.position.y = - objectsDistance * 1
mesh3.position.y = - objectsDistance * 2
// alternate x to match text alignment per section
mesh1.position.x = 2
mesh2.position.x = - 2
mesh3.position.x = 2
  • Keep objectsDistance in a variable: the camera formula, particle spread, and section math all reuse it.

Scroll-synced camera

  • Read window.scrollY in a 'scroll' listener into a variable, then apply it in the tick function. Do not move the camera inside the event handler.
  • The conversion formula: scrolling one viewport height must move the camera exactly one object distance. Negate because scrollY grows downward while the camera must descend on y:
let scrollY = window.scrollY
window.addEventListener('scroll', () => { scrollY = window.scrollY })

// in tick
camera.position.y = - scrollY / sizes.height * objectsDistance
  • Gotcha: raw scrollY is in pixels. Using it directly moves the camera hundreds of units. Always divide by sizes.height first.

Cursor parallax

  • Track the cursor normalized to -0.5 to 0.5 so amplitude is resolution independent and centered:
const cursor = { x: 0, y: 0 }
window.addEventListener('mousemove', (event) => {
    cursor.x = event.clientX / sizes.width - 0.5
    cursor.y = event.clientY / sizes.height - 0.5
})
  • Invert cursor.y when applying it: screen y grows downward, scene y grows upward. Without the negation the vertical parallax feels reversed.
  • Critical conflict: scroll already sets camera.position.y every frame, so writing parallax to the same property overwrites it. Fix by nesting the camera in a Group and applying parallax to the group. Transforms compose, both effects survive:
const cameraGroup = new THREE.Group()
scene.add(cameraGroup)
const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 6
cameraGroup.add(camera)

Easing with lerp

  • Linear cursor tracking feels mechanical. Each frame, move a fraction of the remaining distance toward the target. The closer it gets, the slower it moves:
const parallaxX = cursor.x * 0.5
const parallaxY = - cursor.y * 0.5
cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime
cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime
  • The += and the (target - current) difference are the whole formula. A constant like 0.1 per frame works but is frame-rate dependent.

Delta time for frame-rate independence

  • On a 120Hz screen a per-frame fraction converges twice as fast. Scale by the time between frames instead:
const clock = new THREE.Clock()
let previousTime = 0

const tick = () => {
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - previousTime
    previousTime = elapsedTime
    // ...
}
  • deltaTime is in seconds (about 0.016 at 60fps), so a factor that was 0.1 per frame becomes something like 5 * deltaTime.
  • Use delta time for any continuous animation that must look identical across devices, including the permanent object rotation below.

Triggered animations on section change

  • Detect the current section from scroll math, no library needed when each section is exactly one viewport tall:
let currentSection = 0
window.addEventListener('scroll', () => {
    scrollY = window.scrollY
    const newSection = Math.round(scrollY / sizes.height)
    if(newSection != currentSection) {
        currentSection = newSection
        gsap.to(sectionMeshes[currentSection].rotation, {
            duration: 1.5,
            ease: 'power2.inOut',
            x: '+=6',
            y: '+=3',
            z: '+=1.5'
        })
    }
})
  • Relative values ('+=6') add on top of the current rotation instead of jumping to an absolute angle.
  • Gotcha: GSAP and the tick function fight over the same property. If tick sets mesh.rotation.x = elapsedTime * 0.1 every frame, the GSAP tween is overwritten and appears to do nothing. Make the permanent rotation additive so both compose:
// wrong: absolute assignment kills the tween
mesh.rotation.x = elapsedTime * 0.1
// right: additive, GSAP's contribution survives
mesh.rotation.x += deltaTime * 0.1
mesh.rotation.y += deltaTime * 0.12

Depth particles for scroll scenes

  • Spread Points across the full scroll range so every section has ambient depth. Derive the vertical spread from the same section constants:
positions[i * 3 + 0] = (Math.random() - 0.5) * 10
positions[i * 3 + 1] = objectsDistance * 0.5 - Math.random() * objectsDistance * sectionMeshes.length
positions[i * 3 + 2] = (Math.random() - 0.5) * 10
  • MeshToonMaterial side notes for this setup: it needs a light to render at all (a black object means no light, add a DirectionalLight), and a 3-pixel gradient texture needs gradientTexture.magFilter = THREE.NearestFilter or interpolation smooths the toon steps into a plain gradient.

Raycaster fundamentals

  • A Raycaster shoots a ray from an origin in a direction and reports what it hits: walls in front of a player, laser hits, and most commonly, what is under the mouse.
  • Manual setup takes an origin and a normalized direction (length 1). Always call normalize() even when the vector looks unit-length already, so later edits stay safe:
const raycaster = new THREE.Raycaster()
const rayOrigin = new THREE.Vector3(- 3, 0, 0)
const rayDirection = new THREE.Vector3(10, 0, 0)
rayDirection.normalize()
raycaster.set(rayOrigin, rayDirection)
  • intersectObject(object) tests one object, intersectObjects(array) tests many. Both return an array, always, even for a single object: a ray can pass through the same mesh multiple times (think of a torus, entry and exit on both sides of the ring).
  • Each intersection entry carries: distance (from ray origin), object (the hit mesh), point (Vector3 of the hit in world space), face, faceIndex, uv. Use distance for proximity checks, object to restyle, point to spawn effects at the impact.
  • Results are sorted by distance, closest first, so intersects[0] is the nearest hit.
  • For moving objects, cast on every frame inside tick, not once at startup.

Mouse coordinates for raycasting

  • The raycaster needs normalized device coordinates: -1 to +1 on both axes, with y positive upward. Pixel coordinates will not work:
const mouse = new THREE.Vector2()
window.addEventListener('mousemove', (event) => {
    mouse.x = event.clientX / sizes.width * 2 - 1
    mouse.y = - (event.clientY / sizes.height) * 2 + 1
})
  • Do not cast the ray inside the mousemove handler: the event can fire more often than the frame rate. Store the coordinates and cast in tick.
  • raycaster.setFromCamera(mouse, camera) does the camera-through-cursor math for you. Then intersect as usual:
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(objectsToTest)
  • Restoring non-hovered state: hits only tell you what IS intersected. Reset all candidates to the default state first, then apply the hover state to the hits, or check membership with intersects.find(i => i.object === object).

Hover enter and leave

  • There are no native mouseenter/mouseleave events for meshes. Reproduce them by tracking the currently hovered object across frames:
let currentIntersect = null

// in tick, after intersecting
if(intersects.length) {
    if(!currentIntersect) { /* mouse enter */ }
    currentIntersect = intersects[0]
}
else {
    if(currentIntersect) { /* mouse leave */ }
    currentIntersect = null
}
  • Transition logic: something now, nothing before means enter. Nothing now, something before means leave.

Click picking

  • Listen for 'click' on window and consult the hover state instead of re-casting:
window.addEventListener('click', () => {
    if(currentIntersect) {
        switch(currentIntersect.object) {
            case object1: /* clicked object 1 */ break
            case object2: /* clicked object 2 */ break
        }
    }
})
  • Compare against currentIntersect.object, not currentIntersect (which is the intersection record, not the mesh).

Raycasting against loaded models

  • intersectObject accepts a Group (like gltf.scene): the raycaster tests children recursively by default, children of children included. Pass false as the second parameter to disable recursion; the default is what you want for models.
  • Even against one model you get an array: complex models have multiple meshes, and a single mesh can be pierced more than once from certain angles.
  • The async loading trap, and its fix:
let model = null
gltfLoader.load('./models/Duck/glTF-Binary/Duck.glb', (gltf) => {
    model = gltf.scene
    model.position.y = - 1.2
    scene.add(model)
})

// in tick: model is null until the load completes
if(model) {
    const modelIntersects = raycaster.intersectObject(model)
    if(modelIntersects.length) model.scale.set(1.2, 1.2, 1.2)
    else model.scale.set(1, 1, 1)
}
  • Two distinct bugs hide here: the gltf variable is scoped to the callback (so hoist a let model = null), and the model is not loaded on the first frames (so guard with if(model)). Both are classic when interacting with loaded assets.
  • glTF models typically use MeshStandardMaterial, which renders black without lights. Add an AmbientLight plus a DirectionalLight before assuming the load failed.
  • modelIntersects.length doubles as the hover boolean: 0 is falsy, anything else means the cursor is on the model.

Common mistakes

  • Using raw pixel scrollY or pixel mouse coordinates in 3D math instead of normalizing them
  • Setting camera.position.y from both scroll and parallax, so one overwrites the other, instead of using a camera group
  • Forgetting to invert the y axis (scroll direction, cursor parallax, and NDC mouse y all need negation)
  • Casting rays inside mousemove instead of once per frame in tick
  • Absolute per-frame rotation assignments that silently cancel GSAP tweens, use += deltaTime instead
  • Frame-count-based easing that runs faster on high-refresh screens, always scale by delta time
  • Accessing gltf.scene outside the loader callback, or intersecting a model that has not finished loading
  • Expecting a single hit object from intersectObject: the result is always an array

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "scroll-and-mouse-interaction" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems