webdevpuneet.com

GLB Hotspot Annotation Viewer — Free Three.js glTF Screen-Space Marker Snippet

GLB Hotspot Annotation Viewer · Media · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

<div class="gha-wrap">
  <canvas id="ghaCanvas"></canvas>
  <div class="gha-hint">Drag to orbit · click a dot for details · Ctrl/Cmd + scroll to zoom</div>
  <div class="gha-zoom">
    <span class="gha-zoom-label">+</span>
    <input type="range" id="ghaZoom" class="gha-zoom-slider" min="0" max="100" step="1" />
    <span class="gha-zoom-label">&minus;</span>
  </div>
  <div class="gha-markers" id="ghaMarkers"></div>
  <div class="gha-card" id="ghaCard">
    <button class="gha-card-close" id="ghaCardClose">&times;</button>
    <div class="gha-card-title" id="ghaCardTitle"></div>
    <div class="gha-card-text" id="ghaCardText"></div>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Real glTF binary model
Loaded via THREE.GLTFLoader from an actual .glb file, not a primitive shape.
Real DOM hotspot markers
Genuine, accessible, clickable div elements, not a canvas-drawn overlay.
Per-frame screen-space projection
camera.project() recomputes every marker's pixel position every frame.
Scale-independent hotspot placement
Local-space coordinates convert to world space using the model's own auto-fit scale.
Basic occlusion handling
Markers fade out and stop being clickable when projected behind the camera.
Click-to-open info cards
Each hotspot opens a styled card with a real title and description.
Honest load-failure fallback
A logged error swaps in a placeholder that still carries all four hotspots.
Slider + Ctrl/Cmd-scroll zoom
Zoom is an explicit, opt-in gesture, never a hijacked plain scroll wheel.

About this UI Snippet

GLB Hotspot Annotation Viewer — Real DOM Markers Tracking a 3D Model

Screenshot of the GLB Hotspot Annotation Viewer snippet rendered live

Annotating a 3D model with labeled hotspots means solving one core problem: keeping a flat, clickable 2D marker glued to a specific 3D point on the model as the camera orbits and zooms around it. This snippet solves that with camera.project(), converting each hotspot's fixed world-space position into normalized device coordinates every single frame, and positioning a real absolutely-positioned <div> there — genuine, accessible, styleable DOM elements, not a canvas-drawn overlay.

Defined in local space, converted once the scale is known

Each hotspot in HOTSPOT_DEFS is authored as a local coordinate — a point relative to the model's own unscaled geometry, easy to eyeball from the source asset's proportions. Once the model loads and its auto-fit scale factor is computed, buildHotspots() converts every local point into a fixed world-space Vector3 exactly once, multiplying by that same scale factor and adding the model's final grounded position — so a hotspot always tracks the same physical point on the model regardless of how large or small the auto-fit ends up making it.

camera.project(), recomputed every frame

Inside the render loop, updateMarkers() calls .project(camera) on each hotspot's stored world position, which returns normalized device coordinates in the [-1, 1] range for x and y. A short conversion maps that into actual canvas pixel coordinates, and the marker's <div> gets its left/top CSS set directly — recomputed fresh every frame, so dragging to orbit or zooming with the slider or Ctrl/Cmd + scroll all keep every marker glued exactly where it should be with zero lag.

A simple occlusion check, not a full raycast

projected.z > 1 after projection indicates the point has gone behind the camera's near/far range from this viewing angle — the marker's opacity is set to 0 and its pointer-events disabled in that case, so a hotspot on the far side of the model doesn't sit visibly (and clickably) on top of it. This is a lightweight heuristic, not a full raycasted occlusion test against the model's own geometry, but it's enough to keep markers from feeling obviously wrong.

Real DOM, real accessibility, real info cards

Because markers are genuine <div> elements layered in a position: absolute container over the canvas, they support real click listeners, hover states, and focus — clicking one opens a styled info card anchored to the corner of the viewer with that hotspot's title and description, closable independently of which marker triggered it.

A named, honest fallback if the model fails

If the .glb can't load, a simple box placeholder is built and run through the exact same buildHotspots() function, so the four hotspots still appear (in slightly less meaningful spots) and remain fully clickable.

Zoom is opt-in, not a hijacked scroll wheel

OrbitControls' built-in wheel-zoom is turned off, with zoom reimplemented as a slider plus Ctrl/Cmd + scroll, so a plain scroll over the card always scrolls the page.

Customizing it

Adjust each hotspot's local coordinate to point at different features, add more entries to HOTSPOT_DEFS, or pair this with GLB exploded view assembly toggle to label parts once they're pulled apart.

Step by step

How to Use

  1. 1
    Add all three CDN scriptsthree.min.js, GLTFLoader.js, and OrbitControls.js.
  2. 2
    Paste HTML, CSS, and JSThe camera model loads with four pulsing hotspot markers already tracking it.
  3. 3
    Drag on the canvasOrbit around the model; every marker keeps glued to its point in real time.
  4. 4
    Click a markerAn info card opens describing that specific feature.
  5. 5
    Use the slider or Ctrl/Cmd + scroll to zoomPlain scroll always scrolls the page; markers keep tracking through any zoom.
  6. 6
    Edit HOTSPOT_DEFSChange local coordinates, titles, and text to annotate different features.

Real-world uses

Common Use Cases

Product feature call-outs
Label specific parts of a real product, with genuine clickable DOM markers kept glued to their 3D points as the camera orbits.
Technical manuals
Annotate components of a machine, using camera.project() every frame to convert each fixed 3D point into current pixel coordinates.
Museum and collectible labels
Point out notable features of an object, with local-space coordinates converted to world space so placement survives scaling.
Screen-space projection teaching
Learn the complete route from a 3D point to a screen position, in a form you can adapt to any Three.js scene.
Exploded annotation workflows
Combine with the GLB exploded view toggle so labels keep following their parts as an assembly separates.
Related: Competitive Rank Tier Badge
See the Competitive Rank Tier Badge for a related cards pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

Every hotspot's fixed world-space position is passed through camera.project() once per rendered frame, which returns normalized device coordinates for exactly where that 3D point currently projects onto the 2D screen from the camera's current position and angle. Those coordinates are converted into pixel values and set directly as the marker div's left/top CSS — recomputed fresh every frame, so any orbit or zoom keeps every marker exactly aligned with no lag or drift.

Local coordinates are authored relative to the model's own original, unscaled geometry, which is far easier to eyeball correctly from the source asset's proportions. Once the model loads and its auto-fit scale factor is known, each local coordinate is converted into a fixed world-space position exactly once, using that same scale factor — so a hotspot always tracks the same physical point on the model regardless of how large the auto-fit ends up making it.

After projecting a hotspot's world position through the camera, a projected z value greater than 1 indicates that point is currently behind the camera in its projected depth range. The snippet uses this as a lightweight heuristic to fade out and disable clicking on markers in that state — it is not a full raycast against the model's actual geometry, but it is enough to avoid an obviously wrong-feeling marker sitting on top of the model from the wrong side.

The loader's error callback logs the real failure and builds a simple box placeholder, which is passed through the exact same buildHotspots() function the real model uses — so all four hotspot markers still appear and remain fully clickable, just annotating a placeholder shape instead of the real camera model.

Set up the renderer, scene, GLTFLoader call, OrbitControls, and the hotspots array inside a mount effect, keeping the array and modelRoot in refs so the render loop's updateMarkers() and the card click handlers can reach current values. Call controls.dispose() and renderer.dispose() in the cleanup function to release the WebGL context and drag listeners on unmount.