Bootstrap 5 Route Map with Curved Lines
A dotted map of Europe and the North Atlantic with curved routes fanning out from London, plus controls to adjust the curve and move the arrowheads.
<section class="wdw-stage">
<div class="container" style="max-width: 1080px">
<div class="wdw-card overflow-hidden">
<div class="d-flex flex-column flex-lg-row align-items-lg-center gap-3 p-4 border-bottom">
<div class="me-lg-auto">
<span class="wdw-eyebrow">Route map</span>
<h1 class="wdw-heading h4 mb-0">Flights out of London</h1>
</div>
<div class="d-flex align-items-center gap-3">
<label for="rmCurve" class="small fw-semibold mb-0">Curve</label>
<input type="range" class="form-range rm-range" id="rmCurve" min="0" max="0.6" step="0.01" value="0.3" aria-valuetext="Curve 50%">
<output id="rmCurveText" class="small text-body-secondary rm-out" aria-live="polite">Curve 50%</output>
</div>
<div class="btn-group rm-seg" role="group" aria-label="Arrow position">
<input type="radio" class="btn-check" name="rmArrow" id="rmMid" value="middle" autocomplete="off" checked>
<label class="btn btn-outline-primary btn-sm rounded-start-pill px-3" for="rmMid">Middle</label>
<input type="radio" class="btn-check" name="rmArrow" id="rmEnd" value="end" autocomplete="off">
<label class="btn btn-outline-primary btn-sm rounded-end-pill px-3" for="rmEnd">End</label>
</div>
</div>
<div class="rm-map p-2 p-md-4">
<svg id="rmSvg" viewBox="0 0 1000 480" class="w-100 h-auto d-block rm-svg" role="group" aria-label="Map of Europe and the North Atlantic with curved routes from London to eight cities. Focus a city to highlight its route."></svg>
<p class="d-sm-none small text-body-secondary text-center mb-0 mt-2">Also flying to Reykjavik and New York (off this map).</p>
</div>
</div>
</div>
</section>
/* Web Designer Wall library style — shared base for every Bootstrap 5 snippet.
Indigo accent, Plus Jakarta Sans, soft cards, pill buttons. Works in light and dark. */
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap");
:root,
[data-bs-theme="light"] {
--wdw-font: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--bs-body-font-family: var(--wdw-font);
--bs-primary: #4f46e5;
--bs-primary-rgb: 79, 70, 229;
--bs-primary-text-emphasis: #3730a3;
--bs-primary-bg-subtle: #eef2ff;
--bs-primary-border-subtle: #c7d2fe;
--bs-link-color: #4f46e5;
--bs-link-color-rgb: 79, 70, 229;
--bs-link-hover-color: #4338ca;
--bs-link-hover-color-rgb: 67, 56, 202;
--bs-focus-ring-color: rgba(79, 70, 229, 0.25);
--bs-border-radius: 0.75rem;
--bs-border-radius-lg: 1rem;
--bs-border-radius-xl: 1.25rem;
--wdw-radius-card: 1.25rem;
--wdw-shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 16px 40px -16px rgba(16, 24, 40, 0.22);
--wdw-shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 12px -4px rgba(16, 24, 40, 0.12);
--wdw-stage: #f5f6fb;
}
[data-bs-theme="dark"] {
--bs-primary: #818cf8;
--bs-primary-rgb: 129, 140, 248;
--bs-primary-text-emphasis: #c7d2fe;
--bs-primary-bg-subtle: rgba(129, 140, 248, 0.14);
--bs-primary-border-subtle: rgba(129, 140, 248, 0.35);
--bs-link-color: #a5b4fc;
--bs-link-color-rgb: 165, 180, 252;
--bs-link-hover-color: #c7d2fe;
--bs-link-hover-color-rgb: 199, 210, 254;
--bs-focus-ring-color: rgba(129, 140, 248, 0.35);
--wdw-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 16px 40px -16px rgba(0, 0, 0, 0.7);
--wdw-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px -4px rgba(0, 0, 0, 0.5);
--wdw-stage: #0f1117;
}
body { font-family: var(--wdw-font); -webkit-font-smoothing: antialiased; }
/* Bootstrap compiles its blue into these components; point them at the accent. */
.btn-primary {
--bs-btn-bg: var(--bs-primary); --bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: #4338ca; --bs-btn-hover-border-color: #4338ca;
--bs-btn-active-bg: #3730a3; --bs-btn-active-border-color: #3730a3;
--bs-btn-disabled-bg: var(--bs-primary); --bs-btn-disabled-border-color: var(--bs-primary);
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
[data-bs-theme="dark"] .btn-primary { --bs-btn-color: #0f1117; --bs-btn-hover-bg: #a5b4fc; --bs-btn-hover-border-color: #a5b4fc; --bs-btn-hover-color: #0f1117; --bs-btn-active-color: #0f1117; }
.btn-outline-primary {
--bs-btn-color: var(--bs-primary); --bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary); --bs-btn-hover-border-color: var(--bs-primary);
--bs-btn-active-bg: var(--bs-primary); --bs-btn-active-border-color: var(--bs-primary);
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
.btn { --bs-btn-font-weight: 600; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: .5rem; }
.btn-sm { min-height: 36px; }
.form-control, .form-select { min-height: 44px; border-radius: .75rem; }
.form-control:focus, .form-select:focus, .form-check-input:focus { border-color: rgba(var(--bs-primary-rgb), .6); box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.form-check-input:checked { background-color: var(--bs-primary); border-color: var(--bs-primary); }
.nav-pills { --bs-nav-pills-link-active-bg: var(--bs-primary); }
.nav { --bs-nav-link-color: var(--bs-secondary-color); --bs-nav-link-hover-color: var(--bs-emphasis-color); }
.pagination { --bs-pagination-active-bg: var(--bs-primary); --bs-pagination-active-border-color: var(--bs-primary); --bs-pagination-color: var(--bs-body-color); --bs-pagination-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.progress, .progress-stacked { --bs-progress-bar-bg: var(--bs-primary); }
.list-group { --bs-list-group-active-bg: var(--bs-primary); --bs-list-group-active-border-color: var(--bs-primary); }
.dropdown-menu { --bs-dropdown-link-active-bg: var(--bs-primary); border-radius: .875rem; box-shadow: var(--wdw-shadow-sm); }
.accordion { --bs-accordion-active-bg: var(--bs-primary-bg-subtle); --bs-accordion-active-color: var(--bs-primary-text-emphasis); --bs-accordion-btn-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.badge { font-weight: 600; }
/* Library building blocks — use these instead of re-inventing them per snippet. */
.wdw-stage { min-height: 100vh; display: flex; align-items: center; background: var(--wdw-stage); padding-block: 3rem; }
.wdw-card { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color-translucent); border-radius: var(--wdw-radius-card); box-shadow: var(--wdw-shadow); }
.wdw-card-sm { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color-translucent); border-radius: 1rem; box-shadow: var(--wdw-shadow-sm); }
.wdw-aurora { position: relative; color: #fff; background: radial-gradient(60% 55% at 100% 0, #818cf8 0, transparent 70%), radial-gradient(65% 60% at 0 100%, #ec4899 0, transparent 65%), #1e1b4b; }
.wdw-eyebrow { display: inline-block; font-size: .75rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--bs-primary); }
.wdw-heading { font-weight: 800; letter-spacing: -.02em; color: var(--bs-emphasis-color); }
.wdw-muted { color: var(--bs-secondary-color); }
.wdw-icon { width: 2.75rem; height: 2.75rem; flex: none; display: inline-flex; align-items: center; justify-content: center; border-radius: .875rem; background: var(--bs-primary-bg-subtle); color: var(--bs-primary); }
/* Component */
.rm-map { background: var(--bs-tertiary-bg); }
.rm-dot { fill: var(--bs-secondary-color); opacity: .4; }
.rm-arc { fill: none; stroke: var(--bs-primary); stroke-width: 1.6; stroke-linecap: round; opacity: .55; transition: opacity .15s, stroke-width .15s; }
.rm-arrow { fill: var(--bs-primary); }
.rm-node { fill: var(--bs-body-bg); stroke: var(--bs-primary); stroke-width: 2.2; }
.rm-hub { fill: var(--bs-primary); stroke: var(--bs-body-bg); stroke-width: 3; }
.rm-pulse { fill: var(--bs-primary); opacity: .25; transform-box: fill-box; transform-origin: center; animation: rm-pulse 2.4s ease-out infinite; }
.rm-label { font: 600 11px var(--wdw-font); fill: var(--bs-body-color); paint-order: stroke; stroke: var(--bs-tertiary-bg); stroke-width: 4px; stroke-linejoin: round; }
.rm-range { width: 10rem; margin: 0; }
.rm-seg .btn { min-height: 36px; }
@keyframes rm-pulse { from { transform: scale(.6); opacity: .45; } to { transform: scale(2.6); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .rm-pulse { animation: none; } }
.rm-range::-webkit-slider-thumb { background-color: var(--bs-primary); }
.rm-range::-moz-range-thumb { background-color: var(--bs-primary); }
.rm-range:focus::-webkit-slider-thumb { box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.rm-svg { min-height: 16rem; }
.rm-city { cursor: pointer; outline: none; }
.rm-city:focus-visible .rm-node { stroke-width: 4; r: 7; }
.rm-city:hover .rm-node { r: 6.5; }
.rm-focus .rm-route:not(.rm-on) { opacity: .25; }
.rm-route.rm-on .rm-arc { opacity: 1; stroke-width: 3.2; }
.rm-sm .rm-far { display: none; }
.rm-sm .rm-dot { r: 3.2px; }
.rm-sm .rm-node { r: 6px; }
.rm-sm .rm-label { font-size: 15px; stroke-width: 5px; }
.rm-sm .rm-arc { stroke-width: 2.4; }
.rm-sm .rm-route.rm-on .rm-arc { stroke-width: 4; }
.rm-out { min-width: 4.5rem; }
(function () {
var LON0 = -80, LAT1 = 70, SX = 1000 / 122, SY = 12;
var COLS = 104, ROWS = 47, DLON = 1.2, DLAT = 0.85;
var NS = 'http://www.w3.org/2000/svg';
/* Land mask: Natural Earth 110m land, sampled on a 104 x 47 grid (lon -80..44, lat 70..30), 1 bit per dot, base64. */
var MASK = 'v+AD////AAAAAA/wAA/gAH//+//////AAH/4H/4AAB//////AAAP7gP8AAD//////gAAB/wA/gAD//////wAAQf4Mf4AH/4B///4AwD4P/AA/4AB/wAAD/j/JwP4AP+AAPwAAB/z//8B+AB/AAAAAAA/5///NDAAPwAAAAAA/8f//z4AAD8AAAAAAf/H//8/gAAGAAAAAAH/x///H4QAAAAAAAAA/+Af/z+MAAAAAAAAAO/B//8f/gAAAAAAAQAHgf//H/8AAAAAAAOAF4X//x//AAAAAAADgDOH//8f/4AAAAAAA8AuB///f//gAAAAAAzAIG///3//8AAAAAAcYD////8///gAAAAAHfH/////f//4AAAAABnx/////3//6AAAAAAA8////////5AAAAAAAQ///////+IYAAAAAAAf///////cPAAAAAAAv///////uD4AAAAAAP///////3gCAAAAAAB/////7//+gAAAAAAAP///+x///wAAAAAAAD/5//P//+wAAAAAAAA/+P/hH/+IAAAAAAAIP4z/4A//AAAAAAAAD/wOP8AD/wAAAAAAAA/8Jx/hg/wAAAAAAAAP8AGf/+f4AAAAAAAAD+Agvj//+AAAAAAAAB/gIIx//+gAAAAAAAAf4ACGf//wAAAAAAAAD+ADBj//8AAAAAAAAAPA8QQ///AAAAAAAAACH/AAAH/wAAAAAAAAA//wAYB/wAAAAAAAAAP/8AAAf4AAAAAAAAAP//AAAH8AAAAAAAAAH//+DAD/AAAAAAAAAB///w8A/wAAAAAAAAAf///P//8=';
var hub = { n: 'London', lon: -0.1262, lat: 51.5002 };
/* name, lon, lat, label side, bulge direction, far (off the Europe crop on mobile) */
var cities = [
['Reykjavik', -21.9, 64.14, 'r', 1, true], ['New York', -74, 40.43, 'a', 1, true], ['Lisbon', -9.14, 38.71, 'b', 1, false],
['Madrid', -3.7, 40.42, 'r', -1, false], ['Rome', 12.5, 41.9, 'b', 1, false], ['Stockholm', 18.06, 59.33, 't', 1, false],
['Athens', 23.72, 37.98, 'b', -1, false], ['Moscow', 37.62, 55.76, 'l', 1, false]
];
function px(lon, lat) { return [(lon - LON0) * SX, (LAT1 - lat) * SY]; }
function el(name, attrs, parent) {
var e = document.createElementNS(NS, name);
for (var k in attrs) e.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(e);
return e;
}
function isLand(bytes, i, j) {
var n = j * COLS + i;
return (bytes.charCodeAt(n >> 3) >> (7 - (n & 7))) & 1;
}
function initRouteMap() {
var svg = document.getElementById('rmSvg');
var curve = document.getElementById('rmCurve');
var out = document.getElementById('rmCurveText');
if (!svg || !curve) return;
var bytes = atob(MASK);
var dots = el('g', {}, svg);
for (var j = 0; j < ROWS; j++) {
for (var i = 0; i < COLS; i++) {
if (!isLand(bytes, i, j)) continue;
var p = px(LON0 + i * DLON, 70 - j * DLAT);
el('circle', { cx: p[0].toFixed(1), cy: p[1].toFixed(1), r: 2.6, 'class': 'rm-dot' }, dots);
}
}
var arcs = el('g', {}, svg);
var h = px(hub.lon, hub.lat);
var routes = cities.map(function (c) {
var to = px(c[1], c[2]);
var g = el('g', { 'class': 'rm-route' + (c[5] ? ' rm-far' : '') }, arcs);
var path = el('path', { 'class': 'rm-arc' }, g);
var arrow = el('path', { d: 'M-5 -4 L5 0 L-5 4 Z', 'class': 'rm-arrow' }, g);
return { to: to, dir: c[4], g: g, path: path, arrow: arrow };
});
function draw() {
var k = parseFloat(curve.value);
var label = 'Curve ' + Math.round(k / 0.6 * 100) + '%';
curve.setAttribute('aria-valuetext', label);
if (out) out.textContent = label;
var atEnd = document.getElementById('rmEnd').checked;
routes.forEach(function (r) {
var dx = r.to[0] - h[0], dy = r.to[1] - h[1];
var nx = -dy, ny = dx;
if (ny > 0 || (ny === 0 && nx < 0)) { nx = -nx; ny = -ny; }
nx *= r.dir; ny *= r.dir;
var cx = (h[0] + r.to[0]) / 2 + nx * k;
var cy = (h[1] + r.to[1]) / 2 + ny * k;
r.path.setAttribute('d', 'M' + h[0] + ' ' + h[1] + ' Q' + cx + ' ' + cy + ' ' + r.to[0] + ' ' + r.to[1]);
var t = atEnd ? 0.96 : 0.5;
var x = (1 - t) * (1 - t) * h[0] + 2 * (1 - t) * t * cx + t * t * r.to[0];
var y = (1 - t) * (1 - t) * h[1] + 2 * (1 - t) * t * cy + t * t * r.to[1];
var tx = 2 * (1 - t) * (cx - h[0]) + 2 * t * (r.to[0] - cx);
var ty = 2 * (1 - t) * (cy - h[1]) + 2 * t * (r.to[1] - cy);
var ang = Math.atan2(ty, tx) * 180 / Math.PI;
r.arrow.setAttribute('transform', 'translate(' + x + ' ' + y + ') rotate(' + ang + ') scale(.8)');
});
}
var nodes = el('g', {}, svg);
cities.forEach(function (c, idx) {
var p = px(c[1], c[2]);
var g = el('g', { 'class': 'rm-city' + (c[5] ? ' rm-far' : ''), tabindex: 0, role: 'button', 'aria-pressed': 'false', 'aria-label': 'Highlight route from London to ' + c[0] }, nodes);
el('title', {}, g).textContent = 'London to ' + c[0];
el('circle', { cx: p[0], cy: p[1], r: 14, fill: 'transparent' }, g);
el('circle', { cx: p[0], cy: p[1], r: 4.5, 'class': 'rm-node' }, g);
var off = { r: [9, 4, 'start'], l: [-9, 4, 'end'], t: [0, -10, 'middle'], b: [0, 18, 'middle'], a: [0, 18, 'middle'] }[c[3]];
el('text', { x: p[0] + off[0], y: p[1] + off[1], 'text-anchor': off[2], 'class': 'rm-label' }, g).textContent = c[0];
var route = routes[idx].g;
function on() { route.classList.add('rm-on'); svg.classList.add('rm-focus'); }
function offf() { if (g.getAttribute('aria-pressed') === 'true') return; route.classList.remove('rm-on'); svg.classList.remove('rm-focus'); }
g.addEventListener('mouseenter', on); g.addEventListener('mouseleave', offf);
g.addEventListener('focus', on); g.addEventListener('blur', offf);
function toggle() {
var pressed = g.getAttribute('aria-pressed') === 'true';
g.setAttribute('aria-pressed', String(!pressed));
if (pressed) offf(); else on();
}
g.addEventListener('click', toggle);
g.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
});
el('circle', { cx: h[0], cy: h[1], r: 9, 'class': 'rm-pulse' }, nodes);
el('circle', { cx: h[0], cy: h[1], r: 6.5, 'class': 'rm-hub' }, nodes);
el('text', { x: h[0] - 12, y: h[1] - 12, 'text-anchor': 'end', 'class': 'rm-label', 'font-weight': 800 }, nodes).textContent = 'London';
/* Mobile: crop to Europe (far routes are hidden and listed under the map). */
var mq = window.matchMedia('(max-width: 575.98px)');
function fit() {
svg.setAttribute('viewBox', mq.matches ? '535 96 465 340' : '0 0 1000 480');
svg.classList.toggle('rm-sm', mq.matches);
}
if (mq.addEventListener) mq.addEventListener('change', fit); else mq.addListener(fit);
fit();
curve.addEventListener('input', draw);
document.querySelectorAll('input[name="rmArrow"]').forEach(function (r) { r.addEventListener('change', draw); });
draw();
}
initRouteMap();
})();
Built on Bootstrap 5.3.8 with plain JavaScript — no jQuery. Supports light and dark mode via data-bs-theme.