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

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

Share & Support

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how camera.project() converts a fixed 3D world position into 2D screen pixels every frame, and why authoring hotspots in the model's local space (converted to world space only once the auto-fit scale is known) keeps their placement correct regardless of how large the model ends up being scaled. It's also useful for extending the demo — ask it to add a real raycast-based occlusion check against the model's own geometry instead of the simpler projected-z heuristic, animate markers with a staggered entrance once the model finishes loading, or add a numbered index badge on each marker with a matching numbered list in a sidebar. Use the conversation to build real intuition for screen-space DOM-to-3D tracking before applying the same hotspot pattern to your own annotated glTF model.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "GLB hotspot annotation viewer" in plain HTML, CSS, and JavaScript using Three.js (core, GLTFLoader, and OrbitControls, all loaded from a CDN with no bundler).

Requirements:
- A full-size Three.js scene with OrbitControls (damping enabled, bounded min/max zoom distance) so a visitor can drag to orbit the camera around a loaded 3D model at any time, studio-lit with key and fill lights plus a simple grounding disc.
- Load a real .glb model using THREE.GLTFLoader pointed at a genuine, freely-licensed, CDN-hosted glTF binary URL with several visually distinct features worth annotating (e.g. one of Khronos' official glTF-Sample-Assets models) — do not substitute a primitive geometry.
- After the model loads, measure its bounding box and scale it to a fixed target height rather than a hardcoded scale number, then reposition it to rest on a ground plane.
- Define at least four hotspots as plain data, each with a coordinate expressed in the model's own original local (unscaled) space plus a title and description string. Once the model's final auto-fit scale factor and position are known, convert each hotspot's local coordinate into a single fixed world-space position exactly once — do not recompute or re-derive it every frame.
- Create one real absolutely-positioned DOM element per hotspot, layered over the canvas. On every animation frame, project each hotspot's stored world position through the camera (using a method equivalent to Three.js's Vector3.project()) to get normalized device coordinates, convert those into actual canvas pixel coordinates, and set the marker element's position directly — so the markers track the model in real time through any orbit or zoom with no lag.
- Add a simple occlusion heuristic: when a hotspot's projected depth indicates it is currently behind the camera (not in front of it), fade that marker's opacity to zero and disable its pointer events, so hidden-side markers don't appear clickable on top of the model.
- Clicking a hotspot marker must open a small info card showing that hotspot's title and description, closable independently, and switching between hotspots must update the open card's content without needing to close and reopen it manually.
- Turn off OrbitControls' own wheel-zoom and instead implement zoom as an explicit opt-in gesture: a vertical range-input slider next to the canvas, plus Ctrl/Cmd + scroll wheel — a plain scroll must do nothing and pass through to the page normally.
- Handle the GLTFLoader's error callback by logging the real error and running a simple placeholder mesh through the exact same hotspot-building function used for the real model, so the annotation markers keep working even if the model fails to load.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

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 a real product's specific parts or features directly on its 3D model.
Technical documentation and manuals
Annotate a component with clickable detail cards instead of a static diagram.
Museum/collectible exhibit labels
Point out specific features of an artifact model with real descriptive text.
glTF/screen-space projection teaching demos
A complete, real example of tracking DOM elements to 3D world points.
Real estate and architecture walkthroughs
Annotate specific fixtures or rooms within a loaded 3D scene model.
Alongside other GLB viewers
Pair with GLB exploded view assembly toggle to label exploded parts.

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.