Adding Annotations¶
Hi there! 
In this tutorial we'll add interactive annotation pins to a 3D model. Think product callouts, feature labels, or hotspot guides — anything where you want to draw attention to a specific part of the scene.
We'll be using the Annotations Plugin to place pins in 3D space and style them with CSS.
This tutorial builds off the Basic implementation tutorial.
Let's pin some things!
1. Load the Plugin¶
Start with the basic boilerplate, load the Annotations plugin, and attach it to the viewer:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Annotations</title>
<style>
body {
font-family: sans-serif;
}
#viewer-container {
width: 500px;
height: 500px;
position: relative;
}
</style>
</head>
<body>
<main>
<h1>Annotations</h1>
<div id="viewer-container">
<div class="js-270viewer"
data-270-model="270logo"
data-270-background="#eeeeee"
data-270-plugins="annotations">
</div>
</div>
</main>
<script>window.TSDAPIKEY = 'rest-of-the-owl';</script>
<script src="https://api.270degrees.nl/api/script/latest/viewer.js"></script>
<script src="https://api.270degrees.nl/api/script/latest/annotations.js"></script>
<script>
// We'll add pins here
</script>
</body>
</html>
Just like the Customiser plugin, we load the script after the viewer and pass data-270-plugins="annotations" to attach it.
Annotation methods live under the Annotations namespace: $viewer.Annotations.addPin(...).
2. Wait for the Model to Load¶
Annotation methods are only available once the model is fully loaded — trying to add pins before that won't work. We'll use onLoadComplete via TSDViewer.create() to make sure everything is ready:
<script>
const $viewer = document.querySelector('.js-270viewer');
TSDViewer.create($viewer, {
onLoadComplete: () => {
// The model and Annotations plugin are ready.
// We'll add our pins and controls here.
}
});
</script>
Everything from here on goes inside that onLoadComplete callback.
3. Place Some Pins¶
Let's add a few pins at different positions using addPin. The x, y, z coordinates are in metres from the centre of the model:
$viewer.Annotations.addPin({
name: 'feature-a',
id: 'pin-top',
content: 'Top section',
x: 0,
y: 0.005,
z: 0,
render: false
});
$viewer.Annotations.addPin({
name: 'feature-b',
id: 'pin-front',
content: 'Front face',
x: 0,
y: 0,
z: 0.006,
render: false
});
$viewer.Annotations.addPin({
name: 'feature-c',
id: 'pin-side',
content: 'Side detail',
x: 0.006,
y: 0,
z: 0
});
Three pins should now appear floating over the model. Rotate the model to see them follow their 3D positions.
Not sure where to place pins? Set debug: true on a pin to show a visible dot at its exact 3D position. This makes it much easier to get the coordinates right.
Notice how we set render: false on the first two pins and only let the last one trigger a render — this avoids unnecessary frame draws when adding multiple pins at once.
4. Style the Labels¶
Annotations inject regular DOM elements, so you can style them with CSS. Each pin creates this structure:
<div class="TSD-pin-container feature-a">
<div class="TSD-pin-label">Top section</div>
</div>
Let's add some CSS to make the labels look like proper callout badges:
.TSD-pin-label {
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: .25rem .6rem;
border-radius: 4px;
font-size: .8rem;
white-space: nowrap;
pointer-events: none;
}
Occlusion¶
When a pin is facing away from the camera, its container gets the is-occluded class. Let's fade those out:
.TSD-pin-container.is-occluded {
opacity: 0.2;
transition: opacity 0.3s;
}
Now labels gracefully dim when they rotate out of view. Spin the model to see it in action!
5. Toggle Pin Visibility¶
Let's add some buttons to show and hide pins using showPin and hidePin:
<div class="controls">
<button id="show-all">Show All</button>
<button id="hide-all">Hide All</button>
</div>
document.querySelector('#show-all').addEventListener('click', () => {
$viewer.Annotations.showPin({ name: 'feature-a' });
$viewer.Annotations.showPin({ name: 'feature-b' });
$viewer.Annotations.showPin({ name: 'feature-c' });
});
document.querySelector('#hide-all').addEventListener('click', () => {
$viewer.Annotations.hidePin({ name: 'feature-a' });
$viewer.Annotations.hidePin({ name: 'feature-b' });
$viewer.Annotations.hidePin({ name: 'feature-c' });
});
You could also target pins by id — handy when multiple pins share the same name.
6. End Result¶
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Annotations</title>
<style>
body {
font-family: sans-serif;
}
#viewer-container {
width: 500px;
height: 500px;
position: relative;
}
.controls {
display: flex;
gap: .75rem;
margin-top: 1rem;
}
.controls button {
padding: .4rem .8rem;
}
.TSD-pin-label {
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: .25rem .6rem;
border-radius: 4px;
font-size: .8rem;
white-space: nowrap;
pointer-events: none;
}
.TSD-pin-container.is-occluded {
opacity: 0.2;
transition: opacity 0.3s;
}
</style>
</head>
<body>
<main>
<h1>Annotations</h1>
<div id="viewer-container">
<div class="js-270viewer"
data-270-model="270logo"
data-270-background="#eeeeee"
data-270-plugins="annotations">
</div>
</div>
<div class="controls">
<button id="show-all">Show All</button>
<button id="hide-all">Hide All</button>
</div>
</main>
<script>window.TSDAPIKEY = 'rest-of-the-owl';</script>
<script src="https://api.270degrees.nl/api/script/latest/viewer.js"></script>
<script src="https://api.270degrees.nl/api/script/latest/annotations.js"></script>
<script>
const $viewer = document.querySelector('.js-270viewer');
TSDViewer.create($viewer, {
onLoadComplete: () => {
$viewer.Annotations.addPin({
name: 'feature-a',
id: 'pin-top',
content: 'Top section',
x: 0, y: 0.005, z: 0,
render: false
});
$viewer.Annotations.addPin({
name: 'feature-b',
id: 'pin-front',
content: 'Front face',
x: 0, y: 0, z: 0.006,
render: false
});
$viewer.Annotations.addPin({
name: 'feature-c',
id: 'pin-side',
content: 'Side detail',
x: 0.006, y: 0, z: 0
});
document.querySelector('#show-all').addEventListener('click', () => {
$viewer.Annotations.showPin({ name: 'feature-a' });
$viewer.Annotations.showPin({ name: 'feature-b' });
$viewer.Annotations.showPin({ name: 'feature-c' });
});
document.querySelector('#hide-all').addEventListener('click', () => {
$viewer.Annotations.hidePin({ name: 'feature-a' });
$viewer.Annotations.hidePin({ name: 'feature-b' });
$viewer.Annotations.hidePin({ name: 'feature-c' });
});
}
});
</script>
</body>
</html>
What's Next?¶
You've got interactive annotations pinned in 3D space, styled with CSS, and toggleable with buttons.
Some ideas to explore:
- Use HTML or full
HTMLElementnodes as pincontentfor rich popups - Combine with the Product Customiser to label customisable areas
- Read the full Annotations Plugin docs for all methods
- Share your creations on our Discord