---
title: "Synchronize annotations with a sidebar list | Geosplats sdk"
description: "Connect 3D model annotations bidirectionally with an external sidebar list."
---

> For AI agents: a documentation index is available at [/llms.txt](https://docs.maptiler.com/llms.txt). Markdown versions of all pages are available by appending .md to any URL.

# Synchronize annotations with a sidebar list

This example demonstrates how to build a bidirectional interface connecting 3D model annotations with an external HTML sidebar list using the Roman Colosseum model.

### NPM module setup

```bash
npm install --save @maptiler/geosplats
```

#### main.js

```js
import { SplatModel, Map, config } from "@maptiler/geosplats";
import "@maptiler/geosplats/dist/maptiler-geosplats.css";

config.apiKey = "YOUR_MAPTILER_API_KEY_HERE";

const map = new Map({
  apiKey: config.apiKey,
  container: "map",
  center: [12.4925, 41.89018],
  zoom: 17.2,
  pitch: 55,
  bearing: -25,
});

let splatModel;
map.on("load", () => {
  splatModel = new SplatModel({
    model: "YOUR_MAPTILER_SPLAT_MODEL_ID_HERE",
    revealParams: { enabled: true },
  });
  map.addSplatModel(splatModel);
});

const colosseumPois = [
  {
    id: "arena",
    pos: { x: 0.0, z: 0.0 },
    icon: "🏛️",
    label: "Arena & Hypogeum",
    popup:
      "<h3>Arena & Hypogeum</h3><p>The central combat stage and subterranean maze of chambers, animal cages, and elevator lifts.</p>",
  },
  {
    id: "north-facade",
    pos: { x: 0.0, z: -0.25 },
    icon: "🧱",
    label: "Northern Outer Arc",
    popup:
      "<h3>Northern Outer Arc</h3><p>The best-preserved segment of the four-tiered travertine outer facade, reaching 48 meters high.</p>",
  },
  {
    id: "imperial-gate",
    pos: { x: -0.28, z: 0.02 },
    icon: "👑",
    label: "Imperial Entrance",
    popup:
      "<h3>Imperial Entrance</h3><p>Direct ceremonial portal connecting the arena podium to the imperial palace on the Palatine Hill.</p>",
  },
  {
    id: "gladiator-gate",
    pos: { x: 0.28, z: -0.02 },
    icon: "⚔️",
    label: "Gladiators' Gate",
    popup:
      "<h3>Gladiators' Gate</h3><p>The eastern gate (Porta Libitinensis) leading to the gladiator training barracks at the Ludus Magnus.</p>",
  },
  {
    id: "south-cavea",
    pos: { x: 0.0, z: 0.22 },
    icon: "🏟️",
    label: "Southern Cavea",
    popup:
      "<h3>Southern Cavea</h3><p>The tiered spectator seating once holding over 50,000 citizens organized by social class.</p>",
  },
];

const HIGHLIGHT_COLOR = "#ffcc00";

// 1. Collapsible sidebar logic
const sidebarEl = document.getElementById("sidebar");
const sidebarToggle = document.getElementById("sidebarToggle");
if (sidebarToggle && sidebarEl) {
  sidebarToggle.addEventListener("click", () => {
    sidebarEl.classList.toggle("collapsed");
    sidebarToggle.setAttribute("aria-expanded", !sidebarEl.classList.contains("collapsed"));
  });
}

// 2. Render the sidebar list items immediately on page load
const listEl = document.getElementById("poiList");
if (listEl) {
  colosseumPois.forEach((item) => {
    const li = document.createElement("li");
    li.className = "poi-item";
    li.innerHTML = `<span>${item.icon}</span> <span>${item.label}</span>`;

    // Hover on list item -> highlight 3D annotation
    li.addEventListener("mouseenter", () => {
      if (item.annotation) item.annotation.setColor(HIGHLIGHT_COLOR);
    });
    li.addEventListener("mouseleave", () => {
      if (item.annotation) item.annotation.setColor(undefined);
    });

    // Click on list item -> open annotation popup
    li.addEventListener("click", () => {
      if (item.annotation) item.annotation.openPopup();
    });

    listEl.appendChild(li);
    item.liElement = li;
  });
}

// 3. When the 3D model finishes loading, attach 3D annotations
map.on("splatmodel:load", () => {
  colosseumPois.forEach((item) => {
    const annotation = splatModel.addAnnotation({
      pos: item.pos,
      icon: item.icon,
      label: item.label,
      popup: item.popup,
      data: { id: item.id },
    });

    item.annotation = annotation;
  });
});

// 4. 3D Pin opened -> highlight sidebar item, expand if collapsed, and scroll
map.on("splatmodel:annotation:active", ({ annotation }) => {
  if (sidebarEl && sidebarEl.classList.contains("collapsed")) {
    sidebarEl.classList.remove("collapsed");
    sidebarToggle?.setAttribute("aria-expanded", "true");
  }

  colosseumPois.forEach((item) => {
    if (item.liElement) item.liElement.classList.remove("active");
    if (item.annotation === annotation && item.liElement) {
      item.liElement.classList.add("active");
      item.liElement.scrollIntoView({ behavior: "smooth", block: "nearest" });
    }
  });
});

// 5. 3D Pin closed -> remove active styling
map.on("splatmodel:annotation:inactive", () => {
  colosseumPois.forEach((item) => {
    if (item.liElement) item.liElement.classList.remove("active");
  });
});
```

#### index.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="robots" content="noindex" />
    <title>Synchronize annotations with a sidebar list</title>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div class="sidebar" id="sidebar">
      <div
        class="sidebar-header"
        id="sidebarToggle"
        role="button"
        aria-expanded="true"
        title="Toggle landmarks panel"
      >
        <div class="header-title">
          <span>🏛️</span>
          <h3>Colosseum Landmarks</h3>
        </div>
        <button class="toggle-btn" id="toggleBtn" aria-label="Toggle panel">▲</button>
      </div>
      <div class="sidebar-content" id="sidebarContent">
        <div class="subtitle" id="sidebarSubtitle">Select a landmark to explore:</div>
        <ul id="poiList" class="poi-list"></ul>
      </div>
    </div>
    <div id="map"></div>
    <script type="module" src="./main.js"></script>
  </body>
</html>
```

#### style.css

```css
body {
  font-family: Inter, sans-serif;
  margin: 0;
  overflow: hidden;
}

#map {
  width: 100%;
  height: 100vh;
}

.sidebar {
  position: absolute;
  top: 12px;
  left: 12px;
  width: 240px;
  max-width: calc(100% - 24px);
  background: rgba(255, 255, 255, 0.95);
  backdrop-filter: blur(8px);
  border-radius: 10px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
  z-index: 10;
  overflow: hidden;
  transition: box-shadow 0.25s ease;
  user-select: none;
}

.sidebar-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px;
  cursor: pointer;
  background: transparent;
  transition: background 0.15s ease;
}

.sidebar-header:hover {
  background: rgba(0, 0, 0, 0.04);
}

.header-title {
  display: flex;
  align-items: center;
  gap: 6px;
}

.sidebar h3 {
  font-size: 13.5px;
  margin: 0;
  font-weight: 700;
  color: #222;
  white-space: nowrap;
}

.toggle-btn {
  background: transparent;
  border: none;
  font-size: 10px;
  color: #666;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
  cursor: pointer;
  transition: transform 0.25s ease;
}

.sidebar.collapsed .toggle-btn {
  transform: rotate(180deg);
}

.sidebar-content {
  padding: 0 12px 12px 12px;
  max-height: 260px;
  overflow-y: auto;
  transition:
    max-height 0.25s ease,
    opacity 0.2s ease,
    padding 0.25s ease;
}

.sidebar.collapsed .sidebar-content {
  max-height: 0;
  opacity: 0;
  padding-top: 0;
  padding-bottom: 0;
  pointer-events: none;
}

.sidebar .subtitle {
  font-size: 11px;
  color: #666;
  margin-bottom: 8px;
}

.poi-list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.poi-item {
  padding: 6px 10px;
  border-radius: 6px;
  background: #f8f9fa;
  border: 1px solid #e9ecef;
  cursor: pointer;
  font-size: 12px;
  font-weight: 500;
  color: #333;
  display: flex;
  align-items: center;
  gap: 8px;
  transition:
    background 0.15s ease,
    border-color 0.15s ease,
    color 0.15s ease;
}

.poi-item:hover {
  background: #eef2ff;
  border-color: #c7d2fe;
}

.poi-item.active {
  background: #3174ff;
  color: #fff;
  border-color: #3174ff;
}
```

### Basic JavaScript setup

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Synchronize annotations with a sidebar list</title>
    <link
      rel="stylesheet"
      href="https://cdn.maptiler.com/maptiler-geosplats/v1.2.1/maptiler-geosplats.css"
    />
    <style>
      body {
        font-family: Inter, sans-serif;
        margin: 0;
        overflow: hidden;
      }

      #map {
        width: 100%;
        height: 100vh;
      }

      .sidebar {
        position: absolute;
        top: 12px;
        left: 12px;
        width: 240px;
        max-width: calc(100% - 24px);
        background: rgba(255, 255, 255, 0.95);
        backdrop-filter: blur(8px);
        border-radius: 10px;
        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
        z-index: 10;
        overflow: hidden;
        transition: box-shadow 0.25s ease;
        user-select: none;
      }

      .sidebar-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 10px 12px;
        cursor: pointer;
        background: transparent;
        transition: background 0.15s ease;
      }

      .sidebar-header:hover {
        background: rgba(0, 0, 0, 0.04);
      }

      .header-title {
        display: flex;
        align-items: center;
        gap: 6px;
      }

      .sidebar h3 {
        font-size: 13.5px;
        margin: 0;
        font-weight: 700;
        color: #222;
        white-space: nowrap;
      }

      .toggle-btn {
        background: transparent;
        border: none;
        font-size: 10px;
        color: #666;
        width: 20px;
        height: 20px;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 4px;
        cursor: pointer;
        transition: transform 0.25s ease;
      }

      .sidebar.collapsed .toggle-btn {
        transform: rotate(180deg);
      }

      .sidebar-content {
        padding: 0 12px 12px 12px;
        max-height: 260px;
        overflow-y: auto;
        transition:
          max-height 0.25s ease,
          opacity 0.2s ease,
          padding 0.25s ease;
      }

      .sidebar.collapsed .sidebar-content {
        max-height: 0;
        opacity: 0;
        padding-top: 0;
        padding-bottom: 0;
        pointer-events: none;
      }

      .sidebar .subtitle {
        font-size: 11px;
        color: #666;
        margin-bottom: 8px;
      }

      .poi-list {
        list-style: none;
        padding: 0;
        margin: 0;
        display: flex;
        flex-direction: column;
        gap: 4px;
      }

      .poi-item {
        padding: 6px 10px;
        border-radius: 6px;
        background: #f8f9fa;
        border: 1px solid #e9ecef;
        cursor: pointer;
        font-size: 12px;
        font-weight: 500;
        color: #333;
        display: flex;
        align-items: center;
        gap: 8px;
        transition:
          background 0.15s ease,
          border-color 0.15s ease,
          color 0.15s ease;
      }

      .poi-item:hover {
        background: #eef2ff;
        border-color: #c7d2fe;
      }

      .poi-item.active {
        background: #3174ff;
        color: #fff;
        border-color: #3174ff;
      }
    </style>
  </head>

  <body>
    <div class="sidebar" id="sidebar">
      <div
        class="sidebar-header"
        id="sidebarToggle"
        role="button"
        aria-expanded="true"
        title="Toggle landmarks panel"
      >
        <div class="header-title">
          <span>🏛️</span>
          <h3>Colosseum Landmarks</h3>
        </div>
        <button class="toggle-btn" id="toggleBtn" aria-label="Toggle panel">▲</button>
      </div>
      <div class="sidebar-content" id="sidebarContent">
        <div class="subtitle" id="sidebarSubtitle">Select a landmark to explore:</div>
        <ul id="poiList" class="poi-list"></ul>
      </div>
    </div>
    <div id="map"></div>
    <script type="module">
      import {
        SplatModel,
        Map,
        config,
      } from "https://cdn.maptiler.com/maptiler-geosplats/v1.2.1/maptiler-geosplats.mjs";

      config.apiKey = "YOUR_MAPTILER_API_KEY_HERE";

      const map = new Map({
        apiKey: config.apiKey,
        container: "map",
        center: [12.4925, 41.89018],
        zoom: 17.2,
        pitch: 55,
        bearing: -25,
      });

      let splatModel;
      map.on("load", () => {
        splatModel = new SplatModel({
          model: "YOUR_MAPTILER_SPLAT_MODEL_ID_HERE",
          revealParams: { enabled: true },
        });
        map.addSplatModel(splatModel);
      });

      const colosseumPois = [
        {
          id: "arena",
          pos: { x: 0.0, z: 0.0 },
          icon: "🏛️",
          label: "Arena & Hypogeum",
          popup:
            "<h3>Arena & Hypogeum</h3><p>The central combat stage and subterranean maze of chambers, animal cages, and elevator lifts.</p>",
        },
        {
          id: "north-facade",
          pos: { x: 0.0, z: -0.25 },
          icon: "🧱",
          label: "Northern Outer Arc",
          popup:
            "<h3>Northern Outer Arc</h3><p>The best-preserved segment of the four-tiered travertine outer facade, reaching 48 meters high.</p>",
        },
        {
          id: "imperial-gate",
          pos: { x: -0.28, z: 0.02 },
          icon: "👑",
          label: "Imperial Entrance",
          popup:
            "<h3>Imperial Entrance</h3><p>Direct ceremonial portal connecting the arena podium to the imperial palace on the Palatine Hill.</p>",
        },
        {
          id: "gladiator-gate",
          pos: { x: 0.28, z: -0.02 },
          icon: "⚔️",
          label: "Gladiators' Gate",
          popup:
            "<h3>Gladiators' Gate</h3><p>The eastern gate (Porta Libitinensis) leading to the gladiator training barracks at the Ludus Magnus.</p>",
        },
        {
          id: "south-cavea",
          pos: { x: 0.0, z: 0.22 },
          icon: "🏟️",
          label: "Southern Cavea",
          popup:
            "<h3>Southern Cavea</h3><p>The tiered spectator seating once holding over 50,000 citizens organized by social class.</p>",
        },
      ];

      const HIGHLIGHT_COLOR = "#ffcc00";

      // 1. Collapsible sidebar logic
      const sidebarEl = document.getElementById("sidebar");
      const sidebarToggle = document.getElementById("sidebarToggle");
      sidebarToggle.addEventListener("click", () => {
        sidebarEl.classList.toggle("collapsed");
        sidebarToggle.setAttribute("aria-expanded", !sidebarEl.classList.contains("collapsed"));
      });

      // 2. Render the sidebar list items immediately on page load
      const listEl = document.getElementById("poiList");
      colosseumPois.forEach((item) => {
        const li = document.createElement("li");
        li.className = "poi-item";
        li.innerHTML = `<span>${item.icon}</span> <span>${item.label}</span>`;

        // Hover on list item -> highlight 3D annotation
        li.addEventListener("mouseenter", () => {
          if (item.annotation) item.annotation.setColor(HIGHLIGHT_COLOR);
        });
        li.addEventListener("mouseleave", () => {
          if (item.annotation) item.annotation.setColor(undefined);
        });

        // Click on list item -> open annotation popup
        li.addEventListener("click", () => {
          if (item.annotation) item.annotation.openPopup();
        });

        listEl.appendChild(li);
        item.liElement = li;
      });

      // 3. When the 3D model finishes loading, attach 3D annotations
      map.on("splatmodel:load", () => {
        colosseumPois.forEach((item) => {
          const annotation = splatModel.addAnnotation({
            pos: item.pos,
            icon: item.icon,
            label: item.label,
            popup: item.popup,
            data: { id: item.id },
          });

          item.annotation = annotation;
        });
      });

      // 4. 3D Pin opened -> highlight sidebar item, expand if collapsed, and scroll
      map.on("splatmodel:annotation:active", ({ annotation }) => {
        // Auto-expand sidebar if user clicked a 3D pin directly while collapsed
        if (sidebarEl.classList.contains("collapsed")) {
          sidebarEl.classList.remove("collapsed");
          sidebarToggle.setAttribute("aria-expanded", "true");
        }

        colosseumPois.forEach((item) => {
          if (item.liElement) item.liElement.classList.remove("active");
          if (item.annotation === annotation && item.liElement) {
            item.liElement.classList.add("active");
            item.liElement.scrollIntoView({ behavior: "smooth", block: "nearest" });
          }
        });
      });

      // 5. 3D Pin closed -> remove active styling
      map.on("splatmodel:annotation:inactive", () => {
        colosseumPois.forEach((item) => {
          if (item.liElement) item.liElement.classList.remove("active");
        });
      });
    </script>
  </body>
</html>
```

## Storing annotation references

Populate the sidebar DOM elements upon page initialization and link each entry to its `SplatAnnotation` instance once the 3D model finishes loading:

```js
const listEl = document.getElementById("poiList");

colosseumPois.forEach((item) => {
  const li = document.createElement("li");
  li.innerHTML = `<span>${item.icon}</span> <span>${item.label}</span>`;
  listEl.appendChild(li);
  item.liElement = li;
});

map.on("splatmodel:load", () => {
  colosseumPois.forEach((item) => {
    item.annotation = splatModel.addAnnotation({
      pos: item.pos,
      icon: item.icon,
      label: item.label,
      popup: item.popup,
      data: { id: item.id },
    });
  });
});
```

## Highlighting 3D pins from sidebar hovers

Attach mouse event listeners to the HTML list elements to highlight the corresponding 3D pin dynamically:

```js
const HIGHLIGHT_COLOR = "#ffcc00";

li.addEventListener("mouseenter", () => {
  if (item.annotation) item.annotation.setColor(HIGHLIGHT_COLOR);
});
li.addEventListener("mouseleave", () => {
  if (item.annotation) item.annotation.setColor(undefined);
});

li.addEventListener("click", () => {
  if (item.annotation) item.annotation.openPopup();
});
```

Calling `annotation.setColor(undefined)` reverts the pin back to its default color.

## Updating external UI on active popup changes

Listen for the `"splatmodel:annotation:active"` and `"splatmodel:annotation:inactive"` events on the map to detect when a popup opens or closes (whether from clicking the 3D pin directly or programmatically via the sidebar):

```js
map.on("splatmodel:annotation:active", ({ annotation }) => {
  colosseumPois.forEach((item) => {
    if (item.liElement) item.liElement.classList.remove("active");
    if (item.annotation === annotation && item.liElement) {
      item.liElement.classList.add("active");
      item.liElement.scrollIntoView({ behavior: "smooth", block: "nearest" });
    }
  });
});

map.on("splatmodel:annotation:inactive", () => {
  colosseumPois.forEach((item) => {
    if (item.liElement) item.liElement.classList.remove("active");
  });
});
```

## Learn more

Check out the [MapTiler GeoSplats SDK reference](https://docs.maptiler.com/geosplats-sdk/api) to explore map and model event handlers.

## Related examples

### [Add annotations](https://docs.maptiler.com/geosplats-sdk/examples/add-annotations/)

Examples

Add interactive labelled pins and popups to a splat model.

### [Pin styles and categories](https://docs.maptiler.com/geosplats-sdk/examples/annotation-styles/)

Examples

Style annotations with colors, icons, numbers, and custom popup placement.

### [Guided tour with annotations](https://docs.maptiler.com/geosplats-sdk/examples/annotation-guided-tour/)

Examples

Guide users through model annotations with next and previous buttons.

### [Filter annotations by category](https://docs.maptiler.com/geosplats-sdk/examples/filter-annotations/)

Examples

Filter annotations by category using visibility toggle or color dimming.
