Skip to main content

solid-three has its own pointer-event system, inspired by react-three-fiber. Events are dispatched by raycasting against the scene, then propagate twice: along the ray (through every object the ray hit) and up the scene graph (from the hit object to its ancestors).

You attach handlers as props on any <T.*> or <Entity/> component. The same handler names also work on <Canvas/>, where they fire after tree propagation finishes — handy for catching clicks that land on empty space (see Missed events).

Supported events

Click events (missable)

These fire on the object that was hit, or — when nothing handles them — as a paired *Missed event:

  • onClick / onClickMissed
  • onContextMenu / onContextMenuMissed — right-click
  • onDoubleClick / onDoubleClickMissed

Hover events

Run in three phases per pointer move — enter, then move, then leave. See Hover events.

  • onPointerEnter, onPointerMove, onPointerLeave

Press and wheel events

  • onPointerDown, onPointerUp
  • onWheel — registered as a passive listener

The event object

Every handler receives one event argument that combines the original DOM event with the raycast result.

PropertyTypeWhen presentDescription
nativeEventMouseEvent | PointerEvent | WheelEventalwaysThe original DOM event that triggered the handler.
intersectionsIntersection[]events that raycastAll hit intersections, sorted nearest-first.
intersectionIntersectionevents that raycastShorthand for intersections[0] — the closest hit overall.
objectObject3Devents that raycastThe closest hit object (intersections[0].object) — stable after dispatch, the 3D analogue of a DOM event's target.
currentIntersectionIntersectioninside an object handlerThe intersection for the current handler's object. Absent on canvas-level dispatch.
currentObjectObject3Dinside an object handlerThe object this handler is firing on; as the event bubbles it walks up the ancestor chain (while currentIntersection stays on the hit) and is cleared after dispatch — the 3D analogue of a DOM event's currentTarget.
stoppedbooleanstoppable events onlyWhether stopPropagation() has been called.
stopPropagation() => voidstoppable events onlyStops both raycast and tree propagation. See Stoppable vs non-stoppable.

Not every handler receives every field — what you get depends on the event:

HandlerReceives
onClick, onContextMenu, onDoubleClick, the *Move / *Down / *Up handlers, onWheelnativeEvent, the intersections, and stopPropagation
onPointerEnter, onPointerLeavenativeEvent and the intersections, but no stopPropagation — these can't be stopped
onClickMissed, onContextMenuMissed, onDoubleClickMissedonly nativeEvent — missed events don't raycast
Exact type
type ThreeEvent<TNativeEvent, TConfig = { stoppable: true; intersections: true }> = {
nativeEvent: TNativeEvent
} & (TConfig["stoppable"] extends false ? {} : { stopped: boolean; stopPropagation: () => void }) &
(TConfig["intersections"] extends false
? {}
: {
intersection: Intersection
intersections: Intersection[]
currentIntersection: Intersection
})

The intersection

Each entry in intersections is a standard three.js Intersection:

  • object — the hit Object3D
  • point — world-space hit position (Vector3)
  • distance — distance from the ray origin
  • face, faceIndex — the hit face on the geometry (when available)
  • uv, uv1 — texture coordinates at the hit (when available)
  • normal — the face normal at the hit (when available)
  • instanceId — for InstancedMesh hits

This extra detail is what lets you go beyond "did they click it?". A few of the things each field unlocks:

  • point — drag-to-rotate around the exact spot the user grabbed;
  • uv — paint onto a surface where it was touched;
  • normal — align a world-space gizmo to the surface;
  • distance — react to how far away the hit is.

Reading the event

The event object is created once per dispatch and reused as it bubbles up the chain — the same object is passed to every handler, just like a DOM event. Two things follow from that:

  • Treat it as read-only. intersection, intersections, and the rest are shared scene data, not per-handler copies. Mutating them affects the other handlers in the same dispatch (and, while a pointer is captured, persists across moves). Read from the event; don't write to it.
  • setPointerCapture() captures event.currentObject (the object currently firing), which is cleared after dispatch — so the no-arg form must be called synchronously in the handler. To start a capture later (after an await, a timer), name the object explicitly: setPointerCapture({ object: mesh }). (With no live hit, a deferred capture drags on a camera-facing plane through the object's center.)

Reading the full hit stack

Most handlers care only about the nearest hit, which is what intersection resolves to. For x-ray tools, measure-through-walls, or click-through selection, intersections is sorted nearest-first:

<T.Mesh
onClick={event => {
for (const hit of event.intersections) {
console.log(hit.distance, hit.object.name)
}
}}
/>

Propagation

Each dispatch runs two propagation passes:

  1. Along the ray (3D space) — the handler fires on each hit object in nearest-first order. stopPropagation() skips the remaining objects along the ray.
  2. Up the scene graph (tree) — after an object's handler runs, the event bubbles up its ancestors. stopPropagation() stops this too. If nothing stops the event, it finally fires on the <Canvas/> itself.
const EventPropagation = () => (
<Canvas onClick={() => console.log("4. Canvas clicked (canvas propagation)")}>
<T.Group onClick={() => console.log("3. Group clicked (tree propagation)")}>
<T.Mesh
position={[0, 0, 0]}
onClick={() => console.log("2. Back mesh clicked (raycast propagation)")}
>
<T.BoxGeometry args={[1, 1, 1]} />
<T.MeshBasicMaterial color="blue" />
</T.Mesh>
<T.Mesh
position={[0, 0, 2]} // In front
onClick={event => {
console.log("1. Front mesh clicked (raycast propagation)")
event.stopPropagation() // Stops all propagation
}}
>
<T.BoxGeometry args={[1, 1, 1]} />
<T.MeshBasicMaterial color="red" />
</T.Mesh>
</T.Group>
</Canvas>
)

Without the stopPropagation() call the order would be front mesh → back mesh → group → canvas. Calling it on the front mesh halts all four steps.

Stoppable vs non-stoppable events

Not every event accepts stopPropagation(). The split mirrors the DOM:

StoppableonClick, onContextMenu, onDoubleClick, onPointerDown, onPointerUp, onPointerMove, onWheel.

Non-stoppable — these always fire for every registered handler, regardless of order:

  • onClickMissed, onContextMenuMissed, onDoubleClickMissed — a missed event is by definition the "nothing else handled it" signal.
  • onPointerEnter — enter has to reach the newly-hovered subtree.
  • onPointerLeave — leave has to reach the previously-hovered subtree.

Missed events

A *Missed variant fires on a handler-bearing object when the click, double-click, or context-menu did not reach it. Two cases:

  1. Clicked outside — the ray missed the object and all its descendants.
  2. Blocked by stopPropagation() — another object handled the event first and stopped it.
<T.Mesh onClickMissed={() => console.log("Missed — clicked outside this mesh")}>
<T.BoxGeometry args={[1, 1, 1]} />
<T.MeshBasicMaterial color="blue" />
</T.Mesh>
<T.Group onClickMissed={() => console.log("Group missed — child stopped propagation")}>
<T.Mesh
onClick={event => {
event.stopPropagation()
console.log("Child clicked")
}}
>
<T.BoxGeometry args={[1, 1, 1]} />
<T.MeshBasicMaterial color="red" />
</T.Mesh>
</T.Group>

Common uses: deselecting on a background click, blocking interaction with objects behind a UI layer, or telling a parent container that a child intercepted the event.

Missed events on <Canvas>

The same handlers work on <Canvas/>, where they fire when no object handler at all consumed the ray:

<Canvas onClickMissed={() => deselect()}>
<T.Mesh onClick={() => select()}>
<T.BoxGeometry />
<T.MeshBasicMaterial />
</T.Mesh>
</Canvas>

Use this for canvas-wide "click on empty space" logic without attaching a sentinel mesh.

Hover events

Hover runs in three phases per pointer move, in this order:

  1. Enter — fires for objects newly under the pointer (not in the previous hover set).
  2. Move — fires for everything currently under the pointer.
  3. Leave — fires for objects that were under the pointer but no longer are.

This matches the DOM's mouseenter / mousemove / mouseleave ordering. Enter and leave can't be stopped; move can.

Differences from react-three-fiber

solid-three's hover handling differs from react-three-fiber in three ways:

  • Phase order. solid-three processes all enter events, then all move events, then all leave events (DOM-like). react-three-fiber interleaves them as it traverses.
  • What can be stopped. In solid-three, move can be stopped but enter and leave always fire (DOM-like). In react-three-fiber, every hover event can be stopped.
  • Child to parent. In solid-three, moving from a child to its parent fires only a leave on the child (DOM-like). In react-three-fiber, the parent receives both a leave and an immediate re-enter.

Pointer capture

By default a pointer event goes to whatever the ray currently hits. That breaks down for a drag: the moment the pointer slips off the object — or off the canvas entirely — the moves stop arriving, and the gesture dies mid-stroke.

Pointer capture fixes this. Inside an onPointerDown, onPointerMove, or onPointerUp handler, three methods ride on the event:

MethodDescription
setPointerCapture(options?)Capture the pointer to options.object (default: the firing currentObject; sync-only). Pass options.normal to orient the drag plane.
releasePointerCapture()Release a capture started with setPointerCapture().
hasPointerCapture()Whether this event's node currently holds the capture.

Once captured, every subsequent onPointerMove and onPointerUp for that pointer is delivered exclusively to the captured node's chain — still bubbling up to the canvas-level handler, but no longer raycasting against the rest of the scene. Delivery continues even when the pointer is off the object, and (for the DOM pointer source) off the canvas. Hover is frozen for the duration: no enter/leave fires while a capture is held.

Capture is object-scoped — the no-arg form captures the object whose handler is running, so calling it from a canvas-level handler is a no-op (there's no currentObject there). Pass an explicit target to capture any object, including later/async.

While the pointer is off the ray, event.intersection is reprojected onto a plane through the original grab point, so event.intersection.point keeps tracking a sensible world-space position to drag toward.

The drag plane defaults to the hit surface (or camera-facing when there's no face). Pass an options.normal to constrain it — e.g. { normal: new Vector3(0, 1, 0) } for ground-plane sliding — and it's built through the grab point so the object doesn't jump.

A capture releases when you call releasePointerCapture(), and automatically on pointerup/pointercancel (DOM source) or the paired end event (XR). It is also released if the captured object leaves the scene mid-drag, so a captured pointer never keeps dispatching to an unmounted node.

let grabbing = false
<T.Mesh
onPointerDown={event => {
event.stopPropagation()
event.setPointerCapture() // grab — moves now follow this mesh
grabbing = true
}}
onPointerMove={event => {
if (!grabbing) return
// event.intersection.point tracks the drag plane, even off the mesh
}}
onPointerUp={() => {
grabbing = false // capture auto-releases on pointerup
}}
/>

See the pointer events tour for a runnable drag demo.

Reacting to capture in your UI

For visuals that should follow a drag — scaling or recoloring the grabbed object — read the capture state declaratively with the top-level hasPointerCapture(object) instead of tracking your own grabbing flag:

import { hasPointerCapture } from "solid-three"
let mesh: THREE.Mesh | undefined
;<T.Mesh
ref={mesh}
scale={hasPointerCapture(mesh) ? 1.15 : 1}
onPointerDown={event => event.setPointerCapture()}
/>

hasPointerCapture(object) is a context-free reactive read — no useThree, callable anywhere — that re-runs only when that object's capture status flips. A nullish object (an unmounted ref) reads false. It's the reactive counterpart to the event's own event.hasPointerCapture(), which is the imperative, in-handler check.

Filtering with raycastable

To keep an object from being hit while it still receives events that bubble up from its children, set raycastable={false}. See raycastable for the full prop.

See also

  • Pointer events — the tutorial chapter, with a worked walk-through.
  • raycastable — opt an object out of hit-testing while keeping bubbled events.
  • <Canvas/> — where canvas-level handlers and onClickMissed fire.

Last updated: 6/8/26, 11:20 AM

solid threeA SolidJS renderer for three.js — learn by reading.
Community
github