Handling Events
The events system in Angular Three is inspired by React Three Fiber (R3F) but adapted to Angular’s syntax and conventions. Below, we explore how to handle events, customize interactions, and leverage Angular-specific features.
Document/Window Events
Custom components and directives within the NgtCanvas
component are rendered to the Canvas but you can always attach events to the Document
or Window
objects via host
property.
import { Component } from "@angular/core";import { injectStore } from "angular-three";
@Component({ host: { '(document:keydown)': 'onKeyDown($event)', '(window:scroll)': 'onScroll($event)' }})export class MyCmp { private store = injectStore(); // can still inject Angular Three context store
onKeyDown(event: KeyboardEvent) { // able to handle events on the Document object }
onScroll(event: Event) { // able to handle events on the Window object }}
If you want to attach events to the HTMLCanvasElement
, you can get access this element via the store
@Component({})export class MyCmp { private store = injectStore(); private canvasElement = this.store.gl.domElement(); // Signal<HTMLCanvasElement>
// or you can grab the snapshot if you're sure at this point, the Canvas is available // private canvasElement = this.store.snapshot.gl.domElement; // HTMLCanvasElement}
THREE.js Events
Angular Three supports native THREE.js events using Angular’s event binding syntax. Check out events reference page for details.
Raycast Events
THREE.js objects that implement their own raycast method (meshes, lines, etc.) can be interacted with using Angular’s event binding syntax. Events contain both the browser event and THREE.js event data.
<ngt-mesh (click)="handleClick($event)" (contextmenu)="handleContextMenu($event)" (dblclick)="handleDoubleClick($event)" (pointerup)="handlePointerUp($event)" (pointerdown)="handlePointerDown($event)" (pointerover)="handlePointerOver($event)" (pointerout)="handlePointerOut($event)" (pointermove)="handlePointerMove($event)" (wheel)="handleWheel($event)" (pointermissed)="handlePointerMissed($event)"></ngt-mesh>
Event Data
Here are the interfaces that comprise NgtThreeEvent
which is the interface for the data of these raycast events
export interface NgtIntersection extends THREE.Intersection { /** The event source (the object which registered the handler) */ eventObject: THREE.Object3D;}
export interface NgtIntersectionEvent<TSourceEvent> extends NgtIntersection { /** The event source (the object which registered the handler) */ eventObject: THREE.Object3D; /** An array of intersections */ intersections: NgtIntersection[]; /** vec3.set(pointer.x, pointer.y, 0).unproject(camera) */ unprojectedPoint: THREE.Vector3; /** Normalized event coordinates */ pointer: THREE.Vector2; /** Delta between first click and this event */ delta: number; /** The ray that pierced it */ ray: THREE.Ray; /** The camera that was used by the raycaster */ camera: NgtCamera; /** stopPropagation will stop underlying handlers from firing */ stopPropagation: () => void; /** The original host event */ nativeEvent: TSourceEvent; /** If the event was stopped by calling stopPropagation */ stopped: boolean;}
export type NgtThreeEvent<TEvent> = NgtIntersectionEvent<TEvent> & NgtProperties<TEvent>;
export interface NgtEventHandlers { click?: (event: NgtThreeEvent<MouseEvent>) => void; contextmenu?: (event: NgtThreeEvent<MouseEvent>) => void; dblclick?: (event: NgtThreeEvent<MouseEvent>) => void; pointerup?: (event: NgtThreeEvent<PointerEvent>) => void; pointerdown?: (event: NgtThreeEvent<PointerEvent>) => void; pointerover?: (event: NgtThreeEvent<PointerEvent>) => void; pointerout?: (event: NgtThreeEvent<PointerEvent>) => void; pointerenter?: (event: NgtThreeEvent<PointerEvent>) => void; pointerleave?: (event: NgtThreeEvent<PointerEvent>) => void; pointermove?: (event: NgtThreeEvent<PointerEvent>) => void; pointermissed?: (event: MouseEvent) => void; pointercancel?: (event: NgtThreeEvent<PointerEvent>) => void; wheel?: (event: NgtThreeEvent<WheelEvent>) => void;}
Opt-out of Raycast Events
Since objects with raycast
method are subject to raycast events, you can opt any object out of raycast events by passing null
for [raycast]
input.
<ngt-mesh [raycast]="null"> <!-- ... --></ngt-mesh>
Event Propagation (bubbling)
The event system handles propagation uniquely to accommodate 3D space and object occlusion:
- When a ray intersects multiple objects, the system creates an ordered list of intersections based on distance from the camera
- The event is first delivered to the nearest object
- It then bubbles up through that object’s ancestors (similar to DOM event bubbling)
- After completing that path, it moves to the next nearest object
- This process continues through all intersected objects
This means objects are naturally “transparent” to pointer events by default, even when they handle the event themselves.
stopPropagation()
To make an object “block” events from reaching objects behind it:
export class MyCmp { handlePointerOver(event: NgtThreeEvent<PointerEvent>) { event.stopPropagation(); // Your event handling logic here }}
stopPropagation()
has two effects:
- Stops the event from bubbling up to ancestors.
- Prevents the event from reaching objects further along the ray. This means that if the “blocked” objects were previously delivered
pointerover
events, they will immediately be deliveredpointerout
events.
import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, input } from "@angular/core";import { NgtArgs } from "angular-three";import { NgtsOrbitControls } from "angular-three-soba/controls";import * as THREE from "three";
@Component({ selector: "app-scene-graph", template: ` <ngt-color *args="['#201919']" attach="background" />
<ngt-ambient-light [intensity]="0.5" /> <ngt-spot-light [position]="[0, 8, 4]" [intensity]="Math.PI" [decay]="0" [angle]="2" />
<ngt-group> @for (x of positions; track $index) { @for (y of positions; track $index) { @for (z of positions; track $index) { <ngt-mesh [position]="[x, y, z]" (pointerenter)=" stopPropagation() && $event.stopPropagation(); $any(material).color.set('mediumpurple'); " (pointerleave)=" stopPropagation() && $event.stopPropagation(); $any(material).color.set('#efefef'); " > <ngt-box-geometry /> <ngt-mesh-standard-material #material color="#efefef" [roughness]="0.5" [metalness]="0.5" /> </ngt-mesh> } } } </ngt-group>
<ngts-orbit-controls [options]="{ autoRotate: true, autoRotateSpeed: 0.25 }" /> `, imports: [NgtsOrbitControls, NgtArgs], schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush,})export class SceneGraph { protected readonly Math = Math; protected readonly positions = [-2.5, 0, 2.5];
stopPropagation = input(true);}
Pointer Capturing
Angular Three supports pointer capture for maintaining interaction control:
@Component({ template: ` <ngt-mesh (pointerdown)="onPointerDown($event)" (pointerup)="onPointerUp($event)" > <!-- mesh contents --> </ngt-mesh> `})export class MeshComponent { onPointerDown(event: NgtThreeEvent<PointerEvent>) { event.stopPropagation(); // Capture ensures subsequent pointer events go to this object event.target.setPointerCapture(event.nativeEvent.pointerId); }
onPointerUp(event: NgtThreeEvent<PointerEvent>) { event.stopPropagation(); // Release the capture when interaction is complete event.target.releasePointerCapture(event.nativeEvent.pointerId); }}
Custom Event Configuration
For advanced use cases, you can customize the event system’s behavior using the events
input on NgtCanvas
component. The events
input accepts a function
that expects the store and returns a NgtEventManager<EventTarget>
where EventTarget
is the type of element that events will be attached to.
@Component({ template: ` <ngt-canvas [events]="eventConfig" > <!-- scene contents --> </ngt-canvas> `})export class SceneComponent { eventConfig = (store: NgtStore): NgtEventManager<HTMLElement> => ({ enabled: true, // Enable/disable event system priority: 1, // Event layer priority filter: (items: THREE.Intersection[], store: NgtStore) => items, // Custom intersection filter compute: (event: PointerEvent, store: NgtStore) => { // Custom pointer/raycaster computation store.pointer.set( (event.offsetX / store.get('size').width) * 2 - 1, -(event.offsetY / store.get('size').height) * 2 + 1 ); store.raycaster.setFromCamera(store.pointer, store.get('camera')); } });}
Here’s the NgtEventManager
interface
export interface NgtEventManager<TTarget> { /** Determines if the event layer is active */ enabled: boolean; /** Event layer priority, higher prioritized layers come first and may stop(-propagate) lower layer */ priority: number; /** The compute function needs to set up the raycaster and an xy- pointer */ compute?: NgtComputeFunction; /** The filter can re-order or re-structure the intersections */ filter?: NgtFilterFunction; /** The target node the event layer is tied to */ connected?: TTarget; /** All the pointer event handlers through which the host forwards native events */ handlers?: NgtEvents; /** Allows re-connecting to another target */ connect?: (target: TTarget) => void; /** Removes all existing events handlers from the target */ disconnect?: () => void; /** Triggers a onPointerMove with the last known event. This can be useful to enable raycasting without * explicit user interaction, for instance when the camera moves a hoverable object underneath the cursor. */ update?: () => void;}
Forcing Raycast Updates
By default, raycasting only occurs during user interactions. To force a raycast update (e.g., when camera or objects move under a static cursor), you can call update()
from the NgtEventManager
in the store. More common use-case is to force raycast update in the before render loop to react to Camera movements.
export class MyCmp { constructor() { injectBeforeRender(({ events }) => { // Trigger a raycast with the last known pointer position events.update?.(); }) }}