← Diagrams & maps

Elevation Profile

A route’s height profile draws itself in from start to finish, with the total climb, descent and distance above it and a flag on the summit. Then a marker rides along the line, the ridden part darkening behind it, while a tag shows the height and distance. Point at the chart, or drag along it on a phone, to move the marker yourself.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-elevation-profile to an empty element. It fills the width at 5:2, or give it a height.
  3. List the heights along the route in data-heights, in metres at equal steps, and its length in data-distance.

Options

Add any of these attributes to the same element.

data-heightsHeights at equal steps along the route, separated by commas. Default: a made-up hill climb.
data-distanceLength of the route, in km. Default 20.
data-unitsSet to imperial to label heights in feet and distance in miles (give them in those units).
data-colorColour of the line. Default #7c3aed.
data-durationTime to draw the profile. Default 1600 ms.
data-rideTime for the marker to ride the whole route. Default 7000 ms.
data-loopDraw the profile again after every two rides.

The climb and descent add up every rise and fall between your heights. The marker pauses while someone points at the chart and carries on a couple of seconds after they let go.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Elevation Profile: a route's height profile draws itself in from left
    // to right, the summit gets a flag, and a marker rides along the line with
    // a readout of height and distance. Point at (or drag along) the profile
    // to move the marker yourself.
    // Usage: <div data-elevation-profile data-heights="120,180,340,520,410,690,880,760,530,300" data-distance="24"></div>
    // data-heights are heights (in metres) at equal steps along the route.
    // Optional: data-distance (route length in km, default 20), data-color
    // (line colour, default #7c3aed), data-duration (ms to draw the profile,
    // default 1600), data-ride (ms for the marker to ride the route, default
    // 7000), data-units ("imperial" for feet and miles; heights and distance
    // are then given in feet and miles), data-loop (draw the profile again
    // every few rides).
    var DEFAULTS = {
      heights: '220,260,310,290,380,520,610,580,700,860,940,1020,960,1130,1240,1180,990,880,920,760,610,540,430,380',
      distance: 20, color: '#7c3aed', duration: 1600, ride: 7000,
    };
    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0;

    var css =
      '.elp{position:relative;display:block;width:100%;aspect-ratio:5/2;min-height:13em;line-height:1.2;touch-action:pan-y;user-select:none}' +
      '.elp-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.elp-ax{position:absolute;font-size:.74em;color:#9b91ad;font-variant-numeric:tabular-nums;white-space:nowrap;opacity:0}' +
      '.elp-stats{position:absolute;left:0;top:0;display:flex;gap:.9em;font-size:.78em;font-weight:700;color:#4b4060;opacity:0;white-space:nowrap}' +
      '.elp-stats span{display:inline-flex;align-items:center;gap:.25em}' +
      '.elp-stats i{font-style:normal;color:var(--elp-c)}' +
      '.elp-chip{position:absolute;left:0;top:0;padding:.25em .6em;border-radius:.5em;background:#1f1530;color:#fff;font-size:.74em;font-weight:700;white-space:nowrap;font-variant-numeric:tabular-nums;pointer-events:none;opacity:0;box-shadow:0 .2em .6em rgba(30,10,60,.25)}' +
      '.elp-chip small{font-weight:500;opacity:.7;margin-left:.4em;font-size:1em}' +
      '.elp-peak{position:absolute;left:0;top:0;font-size:.7em;font-weight:800;color:var(--elp-c);white-space:nowrap;translate:-50% -100%;opacity:0;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function mk(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }

    function niceStep(range) {
      var steps = [10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 2500, 5000];
      for (var i = 0; i < steps.length; i++) if (range / steps[i] <= 4) return steps[i];
      return 10000;
    }

    function fmt(n) { return Math.round(n).toLocaleString('en-GB'); }

    function setup(el) {
      var d = el.dataset;
      var s = (el._elp = { id: 'elp-' + (++uid), pos: 0, built: false });
      el.classList.add('elp');
      s.color = d.color || DEFAULTS.color;
      el.style.setProperty('--elp-c', s.color);
      s.h = (d.heights || DEFAULTS.heights).split(',').map(Number).filter(function (v) { return !isNaN(v); });
      if (s.h.length < 2) s.h = [0, 0];
      s.dist = +d.distance || DEFAULTS.distance;
      s.imp = d.units === 'imperial';
      s.hu = s.imp ? ' ft' : ' m';
      s.du = s.imp ? ' mi' : ' km';
      var up = 0, down = 0;
      for (var i = 1; i < s.h.length; i++) {
        var dh = s.h[i] - s.h[i - 1];
        if (dh > 0) up += dh; else down -= dh;
      }
      s.stats = document.createElement('div');
      s.stats.className = 'elp-stats';
      s.stats.innerHTML = '<span><i>&uarr;</i>' + fmt(up) + s.hu + '</span><span><i>&darr;</i>' + fmt(down) + s.hu + '</span><span><i>&harr;</i>' +
        s.dist.toLocaleString('en-GB') + s.du + '</span>';
      s.chip = document.createElement('div');
      s.chip.className = 'elp-chip';
      s.chip.setAttribute('aria-hidden', 'true');
      s.peakLab = document.createElement('div');
      s.peakLab.className = 'elp-peak';
      s.peakLab.setAttribute('aria-hidden', 'true');
      el.appendChild(s.stats);
      if (!el.hasAttribute('aria-label')) {
        el.setAttribute('role', 'img');
        el.setAttribute('aria-label', 'Elevation profile: ' + s.dist + s.du + ', climbing ' + fmt(up) + s.hu +
          ', highest point ' + fmt(Math.max.apply(null, s.h)) + s.hu);
      }
      build(el);
      el.appendChild(s.peakLab);
      el.appendChild(s.chip);

      if (!el.closest('[aria-hidden="true"]')) {
        var drag = function (e) {
          if (!s.ready) return;
          var r = el.getBoundingClientRect();
          s.user = true;
          clearTimeout(s.resume);
          if (s.rideAnim) s.rideAnim.pause();
          moveTo(el, (e.clientX - r.left - s.x0) / (s.x1 - s.x0));
        };
        el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) drag(e); });
        el.addEventListener('pointerdown', drag);
        var leave = function () {
          if (!s.user) return;
          clearTimeout(s.resume);
          s.resume = setTimeout(function () { s.user = false; ride(el); }, 2500);
        };
        el.addEventListener('pointerleave', leave);
        el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') leave(); });
      }
      if (typeof ResizeObserver !== 'undefined') {
        new ResizeObserver(function () {
          if (Math.abs(el.clientWidth - s.W) < 1 && Math.abs(el.clientHeight - s.H) < 1) return;
          build(el);
          if (s.ready) { finish(el); moveTo(el, s.pos); }
        }).observe(el);
      }
    }

    // Lay the chart out at the element's current size, in pixels.
    function build(el) {
      var s = el._elp, fs = parseFloat(getComputedStyle(el).fontSize);
      var W = el.clientWidth, H = el.clientHeight;
      s.W = W; s.H = H;
      if (s.svg) s.svg.remove();
      el.querySelectorAll('.elp-ax').forEach(function (a) { a.remove(); });
      var lo = Math.min.apply(null, s.h), hi = Math.max.apply(null, s.h);
      var step = niceStep(Math.max(1, hi - lo));
      var y0v = Math.floor(lo / step) * step, y1v = Math.ceil(hi / step) * step;
      if (y1v - y0v < step * 2) y1v = y0v + step * 2;
      var x0 = fs * 3.3, x1 = W - fs * 0.8, top = fs * 3.4, bot = H - fs * 1.7;
      s.x0 = x0; s.x1 = x1; s.top = top; s.bot = bot;
      function Y(v) { return bot - (v - y0v) / (y1v - y0v) * (bot - top); }

      // A smooth curve through the heights, sampled finely enough to draw as lines.
      var n = s.h.length, pts = [];
      for (var i = 0; i < n - 1; i++) {
        var p0 = s.h[Math.max(0, i - 1)], p1 = s.h[i], p2 = s.h[i + 1], p3 = s.h[Math.min(n - 1, i + 2)];
        for (var k = 0; k < 12; k++) {
          var t = k / 12, t2 = t * t, t3 = t2 * t;
          var v = 0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
          pts.push([x0 + (i + t) / (n - 1) * (x1 - x0), v]);
        }
      }
      pts.push([x1, s.h[n - 1]]);
      s.pts = pts;
      s.Y = Y;

      var line = 'M' + pts.map(function (p) { return p[0].toFixed(1) + ' ' + Y(p[1]).toFixed(1); }).join('L');
      s.svg = mk('svg', { class: 'elp-svg', width: W, height: H, 'aria-hidden': 'true' });
      el.insertBefore(s.svg, el.firstChild);
      var defs = mk('defs', {}, s.svg);
      var grad = mk('linearGradient', { id: s.id + '-fill', x1: 0, y1: 0, x2: 0, y2: 1 }, defs);
      mk('stop', { offset: 0, 'stop-color': s.color, 'stop-opacity': 0.32 }, grad);
      mk('stop', { offset: 1, 'stop-color': s.color, 'stop-opacity': 0.03 }, grad);
      var clip = mk('clipPath', { id: s.id + '-clip' }, defs);
      s.clipRect = mk('rect', { x: 0, y: 0, width: 0, height: H }, clip);
      var done = mk('clipPath', { id: s.id + '-done' }, defs);
      s.doneRect = mk('rect', { x: 0, y: 0, width: 0, height: H }, done);

      s.grid = mk('g', { stroke: '#ece7f4', 'stroke-width': 1, opacity: 0 }, s.svg);
      for (var g = y0v; g <= y1v + 0.1; g += step) {
        mk('line', { x1: x0, x2: x1, y1: Y(g), y2: Y(g), 'stroke-dasharray': g === y0v ? '' : '3 4' }, s.grid);
        addAx(el, fmt(g) + (g === y1v ? s.hu : ''), x0 - fs * 0.5, Y(g), '-100%', '-50%');
      }
      var ticks = s.dist <= 6 ? 1 : s.dist <= 15 ? 2 : s.dist <= 40 ? 5 : s.dist <= 100 ? 10 : 25;
      for (var dd = 0; dd <= s.dist + 0.001; dd += ticks) {
        var dx = x0 + dd / s.dist * (x1 - x0);
        if (x1 - dx < fs * 2.5 && dd > 0 && dd < s.dist) continue;
        addAx(el, dd + (dd + ticks > s.dist + 0.001 ? s.du : ''), dx, bot + fs * 0.45, dd === 0 ? '-20%' : '-50%', '0');
      }

      var g2 = mk('g', { 'clip-path': 'url(#' + s.id + '-clip)' }, s.svg);
      mk('path', { d: line + 'L' + x1 + ' ' + bot + 'L' + x0 + ' ' + bot + 'Z', fill: 'url(#' + s.id + '-fill)' }, g2);
      mk('path', { d: line, fill: 'none', stroke: s.color, 'stroke-width': Math.max(2, fs * 0.22), 'stroke-linejoin': 'round', opacity: 0.45 }, g2);
      // The part already ridden, drawn bolder.
      mk('path', { d: line, fill: 'none', stroke: s.color, 'stroke-width': Math.max(2.5, fs * 0.3), 'stroke-linejoin': 'round', 'stroke-linecap': 'round', 'clip-path': 'url(#' + s.id + '-done)' }, g2);

      var peak = pts.reduce(function (a, b) { return b[1] > a[1] ? b : a; });
      s.peak = mk('g', { opacity: 0 }, s.svg);
      mk('line', { x1: peak[0], x2: peak[0], y1: Y(peak[1]), y2: Y(peak[1]) - fs * 1.3, stroke: '#1f1530', 'stroke-width': 1.4 }, s.peak);
      mk('path', { d: 'M' + peak[0] + ' ' + (Y(peak[1]) - fs * 1.3) + 'l' + fs * 0.75 + ' ' + fs * 0.27 + 'l-' + fs * 0.75 + ' ' + fs * 0.27 + 'Z', fill: s.color }, s.peak);
      s.peakLab.textContent = fmt(Math.max.apply(null, s.h)) + s.hu;
      s.peakLab.style.left = Math.min(W - fs * 2, Math.max(fs * 2, peak[0])) + 'px';
      s.peakLab.style.top = Y(peak[1]) - fs * 1.5 + 'px';

      s.marker = mk('g', { opacity: 0 }, s.svg);
      s.guide = mk('line', { y1: top - fs * 0.6, y2: bot, stroke: '#1f1530', 'stroke-width': 1, 'stroke-dasharray': '2 3', opacity: 0.5 }, s.marker);
      s.dot = mk('circle', { r: fs * 0.48, fill: '#fff', stroke: s.color, 'stroke-width': fs * 0.24 }, s.marker);
      s.stats.style.left = x0 + 'px';
    }

    function addAx(el, text, x, y, tx, ty) {
      var a = document.createElement('span');
      a.className = 'elp-ax';
      a.setAttribute('aria-hidden', 'true');
      a.textContent = text;
      a.style.left = x + 'px';
      a.style.top = y + 'px';
      a.style.translate = tx + ' ' + ty;
      el.appendChild(a);
    }

    function heightAt(s, x) {
      var p = s.pts, lo = 0, hi = p.length - 1;
      if (x <= p[0][0]) return p[0][1];
      if (x >= p[hi][0]) return p[hi][1];
      while (hi - lo > 1) { var m = (lo + hi) >> 1; if (p[m][0] <= x) lo = m; else hi = m; }
      return p[lo][1] + (p[hi][1] - p[lo][1]) * (x - p[lo][0]) / (p[hi][0] - p[lo][0]);
    }

    function moveTo(el, f) {
      var s = el._elp;
      f = Math.max(0, Math.min(1, f));
      s.pos = f;
      var x = s.x0 + f * (s.x1 - s.x0), v = heightAt(s, x), y = s.Y(v);
      s.marker.setAttribute('opacity', 1);
      s.guide.setAttribute('x1', x); s.guide.setAttribute('x2', x);
      s.dot.setAttribute('cx', x); s.dot.setAttribute('cy', y);
      s.doneRect.setAttribute('width', x);
      s.chip.style.opacity = 1;
      s.chip.innerHTML = fmt(v) + s.hu + '<small>' + (f * s.dist).toFixed(1) + s.du + '</small>';
      var cw = s.chip.offsetWidth, fs = parseFloat(getComputedStyle(el).fontSize);
      s.chip.style.transform = 'translate(' + Math.max(0, Math.min(s.W - cw, x - cw / 2)) + 'px,' + Math.max(fs * 1.25, y - fs * 2.7) + 'px)';
    }

    function finish(el) {
      var s = el._elp;
      s.clipRect.setAttribute('width', s.W);
      [s.grid, s.peak].forEach(function (g) { g.setAttribute('opacity', 1); });
      el.querySelectorAll('.elp-ax').forEach(function (a) { a.style.opacity = 1; });
      s.stats.style.opacity = 1;
      s.peakLab.style.opacity = 1;
    }

    function draw(el) {
      var s = el._elp, dur = +el.dataset.duration || DEFAULTS.duration;
      s.ready = false;
      s.marker.setAttribute('opacity', 0);
      s.chip.style.opacity = 0;
      s.doneRect.setAttribute('width', 0);
      anime.animate(s.grid, { opacity: [0, 1], duration: 500, ease: 'out(2)' });
      anime.animate(el.querySelectorAll('.elp-ax'), { opacity: [0, 1], duration: 500, delay: anime.stagger(30), ease: 'out(2)' });
      anime.animate(s.stats, { opacity: [0, 1], y: [6, 0], duration: 500, ease: 'out(3)' });
      var st = { w: 0 };
      anime.animate(st, { w: 1, duration: dur, delay: 250, ease: 'inOut(2)', onUpdate: function () {
        s.clipRect.setAttribute('width', s.x0 + st.w * (s.x1 - s.x0) + 6);
      }, onComplete: function () { s.clipRect.setAttribute('width', s.W); } });
      anime.animate(s.peak, { opacity: [0, 1], duration: 300, delay: dur + 150 });
      anime.animate(s.peakLab, { opacity: [0, 1], y: [6, 0], duration: 420, delay: dur + 200, ease: 'outBack(2)' });
      clearTimeout(s.timer);
      s.timer = setTimeout(function () { s.ready = true; s.rides = 0; ride(el); }, dur + 650);
    }

    function ride(el) {
      var s = el._elp;
      clearTimeout(s.timer);
      if (s.user || !s.visible) return;
      if (el.dataset.loop !== undefined && s.rides >= 2) { draw(el); return; }
      var st = { f: s.pos >= 0.999 ? 0 : s.pos };
      var dur = (+el.dataset.ride || DEFAULTS.ride) * (1 - st.f);
      s.rideAnim = anime.animate(st, { f: 1, duration: dur, ease: st.f ? 'out(1.5)' : 'inOut(1.5)', onUpdate: function () { moveTo(el, st.f); },
        onComplete: function () {
          s.rides++;
          s.timer = setTimeout(function () {
            anime.animate([s.marker], { opacity: 0, duration: 300 });
            anime.animate(s.chip, { opacity: 0, duration: 300, onComplete: function () {
              s.doneRect.setAttribute('width', 0);
              s.pos = 0;
              s.timer = setTimeout(function () { ride(el); }, 500);
            } });
          }, 1400);
        } });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._elp;
          s.visible = e.isIntersecting;
          if (!e.isIntersecting) { clearTimeout(s.timer); if (s.rideAnim) s.rideAnim.pause(); return; }
          if (!s.played) { s.played = true; draw(el); }
          else if (s.ready) ride(el);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-elevation-profile]').forEach(function (el) {
        if (el._elp) return;
        setup(el);
        if (reduced()) {
          el._elp.ready = true;
          finish(el);
          moveTo(el, 0.5);
        } else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-elevation-profile data-distance="18"
     data-heights="120,180,340,520,410,690,880,760,530,300,240"></div>