Interactive Globe (Mapbox)
A fully interactive 3D globe built on Mapbox GL JS. Displays location cards in a collapsible side panel with fly-to navigation, auto-rotation, marker pins, and keyboard/scroll support. Works with inline HTML or a JavaScript markers config — both are compatible with Webflow CMS.
Setup — External Scripts
<link href="https://api.mapbox.com/mapbox-gl-js/v3.20.0/mapbox-gl.css" rel="stylesheet"><script src="https://api.mapbox.com/mapbox-gl-js/v3.20.0/mapbox-gl.js"></script>Code
<div class="globe-container">
<div
data-rotate-speed="120"
data-globe-init
data-auto-rotate="true"
class="globe-wrap"
>
<!-- Map canvas -->
<div data-globe-map class="globe-map"></div>
<!-- Side info panel -->
<div data-globe-info class="globe-info">
<div class="globe-info__collection">
<div data-globe-list class="globe-info__list">
<!-- Location item — repeat for each location -->
<div
data-globe-item
data-globe-id="sf"
data-globe-lat="37.7749"
data-globe-lng="-122.4194"
class="globe-info__list-item"
>
<div class="globe-info__list-item-visual">
<img src="https://cdn.prod.website-files.com/69ba77d9f38f5e60125bd72c/69babc192f3f347ac851cffa_sf.avif" data-globe-item-image class="globe-info__list-item-img" alt="">
</div>
<div class="globe-info__list-item-text">
<p data-globe-item-city class="globe-info__list-item-label">San Francisco, CA</p>
<h3 data-globe-item-name class="globe-info__list-item-h">San Francisco Office</h3>
</div>
<a data-globe-item-link href="#" class="globe-info__list-item-link">Visit website -></a>
</div>
<!-- Add more [data-globe-item] elements here -->
</div>
</div>
<!-- Close button -->
<div class="globe-close">
<button data-globe-close aria-label="close locations panel" class="globe-close__button">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 12 12" fill="none" class="globe-close__icon">
<path d="M10.75 0.75L0.75 10.75" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round"></path>
<path d="M0.75 0.75L10.75 10.75" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round"></path>
</svg>
</button>
</div>
</div>
<!-- Prev / Next navigation -->
<nav data-globe-nav aria-label="location navigation" class="globe-nav">
<button data-globe-prev aria-label="previous location" class="globe-nav__button">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 12 11" fill="none" class="globe-nav__button-icon">
<path d="M5.19685 11L6.14173 10.0634L1.24724 4.96196V6.03804L6.16063 0.936594L5.21575 0L0 5.5L5.19685 11ZM12 6.21739V4.78261H1.11496V6.21739H12Z" fill="currentColor"></path>
</svg>
</button>
<span data-globe-counter class="globe-nav__counter">1/6</span>
<button data-globe-next aria-label="next location" class="globe-nav__button">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 12 11" fill="none" class="globe-nav__button-icon">
<path d="M6.80315 11L5.85827 10.0634L10.7528 4.96196V6.03804L5.83937 0.936594L6.78425 0L12 5.5L6.80315 11ZM0 6.21739V4.78261H10.885V6.21739H0Z" fill="currentColor"></path>
</svg>
</button>
</nav>
<!-- Re-open button (visible when panel is collapsed) -->
<button data-globe-reopen aria-label="show locations panel" class="globe-reopen">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 24" fill="none" class="globe-reopen__icon">
<path d="M9 19L15.2929 12.7071C15.6834 12.3166 15.6834 11.6834 15.2929 11.2929L9 5" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round"></path>
</svg>
</button>
<!-- Marker template (cloned once per location) -->
<div data-globe-marker-template class="globe-marker">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 24" fill="none" class="globe-marker__icon">
<path d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 7.61305 3.94821 5.32387 5.63604 3.63604C7.32387 1.94821 9.61305 1 12 1C14.3869 1 16.6761 1.94821 18.364 3.63604C20.0518 5.32387 21 7.61305 21 10Z" stroke="currentColor" stroke-miterlimit="10" stroke-width="1.5"></path>
<path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="currentColor" stroke-miterlimit="10" stroke-width="1.5"></path>
</svg>
</div>
</div>
</div>.globe-container {
width: 100%;
margin-left: auto;
margin-right: auto;
position: relative;
}
.globe-wrap {
width: 100%;
height: min(100vh, 100rem);
position: relative;
overflow: clip;
}
.globe-map {
z-index: 0;
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
/* Info panel */
.globe-info {
z-index: 10;
color: #f2f2f2;
width: var(--globe-info-width);
background-color: #2a2727;
border: 1px solid #ffffff1a;
border-radius: 1em;
position: absolute;
top: 1em;
bottom: 1em;
right: 1em;
box-shadow: 0 0 12px #0000001a;
}
[data-globe-info] {
opacity: 1;
visibility: visible;
transform: translate(0em, 0px);
transition: all 0.65s cubic-bezier(0.625, 0.05, 0, 1);
}
[data-globe-init][data-collapsed="true"] [data-globe-info] {
opacity: 0;
visibility: hidden;
transform: translate(4em, 0px);
}
.globe-info__collection {
width: 100%;
height: 100%;
}
.globe-info__list {
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
position: absolute;
inset: 0%;
overflow: hidden auto;
}
.globe-info__list-item {
grid-column-gap: 2em;
grid-row-gap: 2em;
scroll-snap-align: start;
flex-flow: column;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
min-height: 100%;
padding: 2em;
display: flex;
}
.globe-info__list-item-visual {
aspect-ratio: 3 / 2;
border-radius: .75em;
width: 100%;
overflow: hidden;
}
.globe-info__list-item-img {
object-fit: cover;
width: 100%;
height: 100%;
}
.globe-info__list-item-text {
grid-column-gap: .5em;
grid-row-gap: .5em;
flex-flow: column;
justify-content: flex-start;
align-items: flex-start;
margin-bottom: 1em;
display: flex;
}
.globe-info__list-item-label {
opacity: .6;
text-transform: uppercase;
margin-bottom: 0;
font-size: .75em;
}
.globe-info__list-item-h {
margin-top: 0;
margin-bottom: 0;
font-size: 2em;
font-weight: 400;
line-height: 1.2;
}
.globe-info__list-item-link {
color: inherit;
text-decoration: none;
}
/* Close button */
.globe-close {
z-index: 11;
position: absolute;
top: 1em;
right: 1em;
}
.globe-close__button {
color: #201d1d;
background-color: #f2f2f2;
border-radius: 100em;
width: 2.5em;
height: 2.5em;
padding: 0;
}
.globe-close__icon {
width: .625em;
}
/* Navigation */
.globe-nav {
z-index: 10;
grid-column-gap: 1em;
grid-row-gap: 1em;
color: #f2f2f2;
bottom: 1em;
right: calc(var(--globe-info-width) + 2em);
background-color: #2a2727;
border: 1px solid #ffffff1a;
border-radius: 100em;
justify-content: center;
align-items: center;
padding: .25em;
display: flex;
position: absolute;
transition: all 0.65s cubic-bezier(0.625, 0.05, 0, 1);
}
[data-globe-init][data-collapsed="true"] [data-globe-nav] {
right: 1em;
}
.globe-nav__button {
background-color: transparent;
border-radius: 100em;
justify-content: center;
align-items: center;
width: 2em;
height: 2em;
padding: 0;
display: flex;
}
.globe-nav__button-icon {
width: .75em;
}
.globe-nav__counter {
text-transform: uppercase;
margin-bottom: 0;
font-size: .875em;
}
/* Re-open button */
.globe-reopen {
z-index: 12;
color: #f2f2f2;
background-color: #2a2727;
border: 1px solid #ffffff26;
border-right-style: none;
border-top-left-radius: .5em;
border-bottom-left-radius: .5em;
height: 3em;
padding: 0 .5em;
position: absolute;
top: 50%;
right: 0;
transform: translate(0, -50%);
}
.globe-reopen__icon {
width: 1em;
}
[data-globe-reopen] {
opacity: 0;
visibility: hidden;
transform: translate(2em, 0px);
transition: all 0.4s cubic-bezier(0.625, 0.05, 0, 1);
}
[data-globe-init][data-collapsed="true"] [data-globe-reopen] {
opacity: 1;
visibility: visible;
transform: translate(0em, 0px);
}
/* Markers */
.globe-marker {
color: #201d1d;
background-color: #ffe19e;
border-radius: 100em;
justify-content: center;
align-items: center;
width: 2.5em;
height: 2.5em;
padding: .625em;
display: flex;
position: relative;
}
.globe-marker__icon {
width: 100%;
}
[data-globe-marker][data-active="true"] {
outline: 1px solid #fff;
outline-offset: .5em;
}
[data-globe-init="initialized"] [data-globe-marker-template] {
display: none;
}
/* Mapbox UI overrides */
.mapboxgl-ctrl-group button {
background: #2a2727 !important;
}
.mapboxgl-ctrl button .mapboxgl-ctrl-icon {
filter: invert(1);
}
.mapboxgl-ctrl-logo,
.mapboxgl-ctrl-attrib {
display: none !important;
}
.mapboxgl-ctrl-group button + button {
border-top: 1px solid #ffffff1a;
}
/* Mobile */
@media screen and (max-width: 991px) {
.globe-wrap {
flex-flow: column;
height: auto;
display: flex;
}
.globe-map {
aspect-ratio: 1;
flex: none;
height: auto;
position: relative;
inset: auto;
}
.globe-info {
z-index: auto;
box-shadow: none;
border-style: solid none none;
border-radius: 0;
width: 100%;
position: relative;
inset: auto;
}
.globe-info__list {
scroll-snap-type: x mandatory;
display: flex;
position: relative;
inset: auto;
overflow: auto hidden;
}
.globe-info__list-item {
scroll-snap-align: center;
border-right: 1px solid #ffffff1a;
flex: 0 0 80%;
min-width: 80%;
padding: 1.5em 1.5em 2em;
}
.globe-info__list-item-text {
margin-bottom: 0;
}
.globe-info__list-item-h {
font-size: 1.5em;
}
.globe-close {
display: none;
}
.globe-nav {
border-left-style: none;
border-right-style: none;
border-radius: 0;
justify-content: space-between;
align-items: center;
padding: 1em 1.5em;
position: relative;
inset: auto;
}
.globe-reopen {
display: none;
}
}
@media screen and (max-width: 479px) {
.globe-info__list-item {
grid-column-gap: 1em;
grid-row-gap: 1em;
padding-top: 1em;
padding-left: 1em;
padding-right: 1em;
}
.globe-info__list-item-visual {
border-radius: .5em;
}
}function initInteractiveGlobeMapbox() {
const cfg = {
mapboxToken: /* "your-mapbox-token-here" */ null,
mapStyle: "mapbox://styles/osmo-supply/cmmw1zil7003e01s84w3h740n",
center: [0, 20],
zoom: 3,
projection: "globe",
autoRotate: true,
secondsPerRevolution: 120,
maxSpinZoom: 5,
slowSpinZoom: 3,
flyToDuration: 2000,
flyToZoom: 5,
globeOffsetX: -0.2,
globeOffsetY: 0.25,
mobile: {
zoom: 2,
flyToZoom: 2.5,
globeOffsetX: 0,
globeOffsetY: 0.5,
},
// Optional: populate markers from JS instead of HTML
// markers: [
// { id: "sf", lat: 37.7749, lng: -122.4194, name: "San Francisco Office",
// city: "San Francisco, CA", image: "sf.jpg", link: "https://example.com" },
// ],
};
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const isSmall = () => window.matchMedia("(max-width: 991px)").matches;
const val = (key) => (isSmall() && cfg.mobile && cfg.mobile[key] !== undefined)
? cfg.mobile[key] : cfg[key];
const wrapper = document.querySelector("[data-globe-init]");
if (!wrapper || wrapper.dataset.globeInit === "initialized") return;
if (typeof mapboxgl === "undefined") return;
if (!cfg.mapboxToken) { console.warn("Interactive Globe: mapboxToken is missing."); return; }
mapboxgl.accessToken = cfg.mapboxToken;
const mapEl = wrapper.querySelector("[data-globe-map]");
if (!mapEl) return;
mapEl.id = "globe-map";
const markers = readMarkers(wrapper);
const firstMarker = markers.length ? markers[0] : null;
const map = new mapboxgl.Map({
container: mapEl.id,
style: cfg.mapStyle,
center: firstMarker ? [firstMarker.lng, firstMarker.lat] : cfg.center,
zoom: firstMarker ? val("flyToZoom") : val("zoom"),
projection: cfg.projection,
attributionControl: false,
cooperativeGestures: true,
});
map.addControl(new mapboxgl.AttributionControl({ compact: true }));
map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), "top-left");
const getPadding = (extraRight) => {
const w = mapEl.offsetWidth;
const h = mapEl.offsetHeight;
const ox = val("globeOffsetX");
const oy = val("globeOffsetY");
return {
top: Math.max(0, oy * h),
bottom: Math.max(0, -oy * h),
left: Math.max(0, ox * w),
right: Math.max(0, -ox * w) + (extraRight || 0),
};
};
map.on("load", () => {
const infoEl = wrapper.querySelector("[data-globe-info]");
const infoW = !isSmall() && infoEl && infoEl.offsetWidth > 0
? infoEl.offsetWidth + 24 : 0;
map.setPadding(getPadding(infoW));
const pins = addPins(map, markers, wrapper);
initSlider(wrapper, markers, map, pins);
if (cfg.autoRotate && !reducedMotion) initSpin(map);
initPanel(wrapper, map, getPadding);
initResize(map, wrapper, getPadding);
wrapper.dataset.globeInit = "initialized";
});
// ── Read markers ──────────────────────────────────────────────────────────
function readMarkers(wrapper) {
const list = wrapper.querySelector("[data-globe-list]");
const items = Array.from(wrapper.querySelectorAll("[data-globe-item]"));
if (list && items.length && cfg.markers && cfg.markers.length) {
const tpl = items[0];
items.forEach(el => el.remove());
return cfg.markers.map((m, i) => {
const clone = tpl.cloneNode(true);
clone.setAttribute("data-globe-id", m.id || "loc-" + i);
clone.setAttribute("data-globe-lat", m.lat);
clone.setAttribute("data-globe-lng", m.lng);
const img = clone.querySelector("[data-globe-item-image]");
const city = clone.querySelector("[data-globe-item-city]");
const name = clone.querySelector("[data-globe-item-name]");
const link = clone.querySelector("[data-globe-item-link]");
if (img) img.src = m.image || "";
if (city) city.textContent = m.city || "";
if (name) name.textContent = m.name || "";
if (link) link.href = m.link || "#";
list.appendChild(clone);
return { id: m.id || "loc-" + i, lat: m.lat, lng: m.lng,
name: m.name || "", city: m.city || "", image: m.image || "",
link: m.link || "", element: clone };
});
}
return items.map((el, i) => ({
id: el.getAttribute("data-globe-id") || "loc-" + i,
lat: parseFloat(el.getAttribute("data-globe-lat")) || 0,
lng: parseFloat(el.getAttribute("data-globe-lng")) || 0,
name: txt(el, "[data-globe-item-name]") || "Location " + (i + 1),
city: txt(el, "[data-globe-item-city]") || "",
image: attr(el, "[data-globe-item-image]", "src") || "",
link: attr(el, "[data-globe-item-link]", "href") || "",
element: el,
}));
}
// ── Map pins ──────────────────────────────────────────────────────────────
function addPins(map, markers, wrapper) {
const tpl = wrapper.querySelector("[data-globe-marker-template]");
return markers.map(data => {
let el;
if (tpl) {
el = tpl.cloneNode(true);
el.removeAttribute("data-globe-marker-template");
} else {
el = document.createElement("div");
el.className = "globe-marker";
el.innerHTML = '<svg class="globe-marker__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>';
}
el.setAttribute("data-globe-marker", data.id);
const marker = new mapboxgl.Marker({ element: el, anchor: "center" })
.setLngLat([data.lng, data.lat])
.addTo(map);
return { marker, element: el, data };
});
}
function setActive(pins, idx) {
pins.forEach((p, i) => {
const v = i === idx ? "true" : "false";
p.element.setAttribute("data-active", v);
if (p.data.element) p.data.element.setAttribute("data-active", v);
});
}
// ── Slider ────────────────────────────────────────────────────────────────
function initSlider(wrapper, markers, map, pins) {
const list = wrapper.querySelector("[data-globe-list]");
const prevBtn = wrapper.querySelector("[data-globe-prev]");
const nextBtn = wrapper.querySelector("[data-globe-next]");
const counter = wrapper.querySelector("[data-globe-counter]");
if (!list || !markers.length) return;
let cur = 0;
let flying = false;
const total = markers.length;
const count = () => { if (counter) counter.textContent = (cur + 1) + " / " + total; };
const flyTo = (i) => {
map.flyTo({
center: [markers[i].lng, markers[i].lat],
zoom: val("flyToZoom"),
duration: cfg.flyToDuration,
essential: true,
});
};
const go = (i) => {
i = ((i % total) + total) % total;
if (i === cur && markers[i].element.getAttribute("data-active") === "true") return;
cur = i;
flying = true;
markers[i].element.scrollIntoView({ behavior: "smooth", block: "start", inline: "start" });
setActive(pins, i);
count();
flyTo(i);
setTimeout(() => { flying = false; }, cfg.flyToDuration + 200);
};
if (prevBtn) prevBtn.addEventListener("click", () => go(cur - 1));
if (nextBtn) nextBtn.addEventListener("click", () => go(cur + 1));
const observer = new IntersectionObserver(entries => {
if (flying) return;
entries.forEach(entry => {
if (!entry.isIntersecting || entry.intersectionRatio <= 0.5) return;
const idx = markers.findIndex(m => m.element === entry.target);
if (idx !== -1 && idx !== cur) { cur = idx; setActive(pins, idx); count(); flyTo(idx); }
});
}, { root: list, threshold: 0.5 });
markers.forEach(m => { if (m.element) observer.observe(m.element); });
markers.forEach((m, i) => {
if (m.element) {
m.element.addEventListener("click", e => {
if (e.target.closest("[data-globe-item-link]")) return;
go(i);
});
}
});
pins.forEach((p, i) => { p.element.addEventListener("click", () => go(i)); });
list.setAttribute("tabindex", "0");
list.addEventListener("keydown", e => {
if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); go(cur + 1); }
if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); go(cur - 1); }
});
setActive(pins, 0);
count();
}
// ── Auto-rotate ───────────────────────────────────────────────────────────
function initSpin(map) {
let interacting = false;
const spin = () => {
const z = map.getZoom();
if (interacting || z >= cfg.maxSpinZoom) return;
let speed = 360 / cfg.secondsPerRevolution;
if (z > cfg.slowSpinZoom) {
speed *= (cfg.maxSpinZoom - z) / (cfg.maxSpinZoom - cfg.slowSpinZoom);
}
const c = map.getCenter();
c.lng -= speed;
map.easeTo({ center: c, duration: 1000, easing: n => n });
};
map.on("mousedown", () => { interacting = true; });
map.on("touchstart", () => { interacting = true; });
["mouseup", "dragend", "pitchend", "rotateend", "touchend"].forEach(e =>
map.on(e, () => { interacting = false; spin(); })
);
map.on("moveend", spin);
spin();
}
// ── Panel toggle ──────────────────────────────────────────────────────────
function initPanel(wrapper, map, getPadding) {
const info = wrapper.querySelector("[data-globe-info]");
const close = wrapper.querySelector("[data-globe-close]");
const open = wrapper.querySelector("[data-globe-reopen]");
if (!info) return;
const sync = () => {
const collapsed = wrapper.getAttribute("data-collapsed") === "true";
const w = (!isSmall() && !collapsed) ? info.offsetWidth + 24 : 0;
map.setPadding(getPadding(w));
if (collapsed) map.flyTo({ center: cfg.center, zoom: val("zoom"),
duration: cfg.flyToDuration, essential: true });
};
new MutationObserver(sync).observe(wrapper, { attributes: true, attributeFilter: ["data-collapsed"] });
if (close) close.addEventListener("click", () => wrapper.setAttribute("data-collapsed", "true"));
if (open) open.addEventListener("click", () => wrapper.setAttribute("data-collapsed", "false"));
}
// ── Resize ────────────────────────────────────────────────────────────────
function initResize(map, wrapper, getPadding) {
const mql = window.matchMedia("(max-width: 991px)");
const infoEl = wrapper.querySelector("[data-globe-info]");
const apply = () => {
map.resize();
const collapsed = wrapper.getAttribute("data-collapsed") === "true";
const w = (!mql.matches && infoEl && !collapsed) ? infoEl.offsetWidth + 24 : 0;
map.setPadding(getPadding(w));
};
mql.addEventListener("change", apply);
let t;
window.addEventListener("resize", () => {
clearTimeout(t);
t = setTimeout(apply, 200);
}, { passive: true });
}
// ── Helpers ───────────────────────────────────────────────────────────────
function txt(parent, sel) {
const el = parent.querySelector(sel);
return el ? el.textContent.trim() : "";
}
function attr(parent, sel, key) {
const el = parent.querySelector(sel);
return el ? el.getAttribute(key) : "";
}
}
document.addEventListener("DOMContentLoaded", () => {
initInteractiveGlobeMapbox();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| [data-globe-init] | attribute | — | The outermost wrapper element. The script mounts here and sets [data-globe-init="initialized"] after load to prevent duplicate initialization. Collapsed state is toggled via [data-collapsed="true/false"] on this element. |
| [data-globe-map] | attribute | — | The element that becomes the Mapbox canvas. Should fill the wrapper. |
| [data-globe-list] | attribute | — | Container for all [data-globe-item] location cards. |
| [data-globe-item] | attribute | — | Each location card. Must include [data-globe-lat], [data-globe-lng], and [data-globe-id]. When cfg.markers is used, the first item acts as a clone template. |
| [data-globe-lat] / [data-globe-lng] | attribute | — | Latitude and longitude for the location. Used to place the map pin and fly the camera. Look up coordinates at latlong.net or by right-clicking in Google Maps. |
| [data-globe-id] | attribute | — | Unique identifier linking each location card to its map pin. |
| [data-globe-item-name] / [data-globe-item-city] / [data-globe-item-image] / [data-globe-item-link] | attribute | — | Content fields inside each location card. Used by the clone logic when populating from cfg.markers. |
| [data-globe-info] | attribute | — | The info panel wrapper. Its width is added to the map padding on desktop so the globe stays centred in the visible area. |
| [data-globe-close] / [data-globe-reopen] | attribute | — | Buttons that toggle [data-collapsed] on the wrapper. Close sets it to "true"; reopen sets it to "false". |
| [data-globe-prev] / [data-globe-next] | attribute | — | Navigation buttons. Move between locations and trigger the fly animation. |
| [data-globe-counter] | attribute | — | Displays the current position as "1 / 6". |
| [data-globe-marker-template] | attribute | — | Blueprint for map pins. Cloned once per location and placed on the map. Hidden after initialization. If absent, a default SVG pin is used. |
| [data-active="true"] | attribute | — | Set by the script on both the active location card and its map pin. Style this in CSS to highlight the selected location. |
Notes
- •Requires a Mapbox access token. Create a free account at mapbox.com and paste the token into cfg.mapboxToken in the JavaScript.
- •The map style URL points to an Osmo Mapbox Studio style. Replace mapStyle with your own style URL to fully customise the map appearance.
- •Locations can be supplied either via HTML [data-globe-item] elements (Webflow CMS-friendly) or through the cfg.markers array in JavaScript. When cfg.markers is present, only one [data-globe-item] is needed as a clone template.
- •globeOffsetX and globeOffsetY shift the visual centre of the globe to account for the side panel. Values are normalised: -0.2 on X shifts the globe 20% to the left.
- •Any cfg value can be overridden on mobile (< 992px) by adding it inside the cfg.mobile object.
- •Auto-rotation respects prefers-reduced-motion and stops automatically when the user interacts with the map.
Guide
Mapbox Token
The globe runs on Mapbox GL JS and requires an access token to load. Create a free account at mapbox.com, then grab your token from the account dashboard. Paste it into mapboxToken inside the config object. For production, tokens can be scoped to specific domains so they only work on your site — see the Mapbox docs on URL restrictions.
const cfg = {
mapboxToken: "pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJja..."
};Map Style
The look and feel of the map is controlled by mapStyle, which points to a style URL from Mapbox Studio. Studio lets you design fully custom map styles — adjust colors, typography, terrain, borders, and more. The demo uses a simple background colour with country borders, but Mapbox supports incredibly rich data and customization.
mapStyle: "mapbox://styles/your-account/your-style-id",Locations
Each location card lives inside [data-globe-list] as a [data-globe-item] element. Every item needs [data-globe-lat] and [data-globe-lng] to place it on the map — lat is the north-south position (-90 to 90), lng is the east-west position (-180 to 180). Look up coordinates at latlong.net or by right-clicking in Google Maps. A [data-globe-id] links each card to its map pin. Content is pulled from [data-globe-item-name], [data-globe-item-city], [data-globe-item-image], and [data-globe-item-link].
Loading Data — from HTML
Add [data-globe-item] elements with data attributes directly in the markup and the script reads them as-is. This is how the demo works and what makes Webflow CMS integration possible.
Loading Data — from JavaScript (cfg.markers)
When cfg.markers is present, the script uses the first [data-globe-item] in HTML as a clone template, removes it, then clones and populates it for each entry. Only one [data-globe-item] element is needed in the HTML when using this approach.
const cfg = {
markers: [
{
id: "sf",
lat: 37.7749,
lng: -122.4194,
name: "San Francisco Office",
city: "San Francisco, CA",
image: "https://example.com/sf.jpg",
link: "https://example.com",
},
{
id: "london",
lat: 51.5074,
lng: -0.1276,
name: "London Office",
city: "London, UK",
image: "https://example.com/london.jpg",
link: "https://example.com",
},
],
};Panel & Collapsed State
The info panel wrapper is marked with [data-globe-info]. The map automatically adjusts its padding to account for the panel width on desktop. [data-globe-close] sets [data-collapsed="true"] on the wrapper; [data-globe-reopen] sets it back to "false". All collapsed-state styling should target [data-globe-init][data-collapsed="true"] in CSS.
Center, Zoom & Globe Offset
The default viewport is set through center and zoom. When locations exist, the globe opens on the first one at flyToZoom level. The globe's visual center can be shifted to make room for the side panel via globeOffsetX and globeOffsetY — values are normalised fractions of the container width/height.
center: [0, 20],
zoom: 3,
flyToZoom: 5,
globeOffsetX: -0.2, // shift 20% to the left
globeOffsetY: 0.25, // shift 25% downwardMobile Overrides
Any config value can be overridden on screens below 992px by adding it to the mobile object. Only the keys you include are overridden; everything else falls back to the top-level values.
mobile: {
zoom: 2,
flyToZoom: 2.5,
globeOffsetX: 0,
globeOffsetY: 0.5,
},Auto-Rotate
When autoRotate is enabled, the globe slowly spins until the user interacts with it. Rotation stops above maxSpinZoom and slows down between slowSpinZoom and maxSpinZoom. Automatically disabled when prefers-reduced-motion is active.
autoRotate: true,
secondsPerRevolution: 120,
maxSpinZoom: 5,
slowSpinZoom: 3,Fly Animation
Navigating between locations triggers a fly animation. Duration in milliseconds is set through flyToDuration.
flyToDuration: 2000,Webflow CMS
The HTML structure maps directly to Webflow CMS collections. .globe-info__collection = Collection List Wrapper, .globe-info__list = Collection List, .globe-info__list-item = Collection Item. Drop a CMS Collection List into the info panel, bind it to your locations collection, and add [data-globe-item], [data-globe-lat], [data-globe-lng], [data-globe-id] onto the Collection Item.