Pure CSS Data & tools #loop #globe #map #flights #data

Flight routes globe in pure CSS

A solid blue globe turning on a tilted axis, with six flight routes arcing off its surface between ten cities and a plane flying each one, round the back and out again. The sphere is a single camera-facing disc that hides whatever turns behind its middle, and each arc is a half circle standing on the chord between its two cities, leant until its plane is nearly level with the equator, so under a camera looking 28° down no arc ever turns edge-on.

  • 130 lines of CSS
  • 34 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The globe's body is one flat disc that always faces you, with a lit-from-above radial-gradient, standing at the sphere's middle (z = 0). Everything that turns — grid rings, city dots, routes — shares its preserve-3d space, so the browser splits each of them where it crosses the disc: what is in front of the middle is drawn, what is behind is hidden. One element makes a solid sphere.
  2. A flight path is a half circle (a box with a top, left and right border, its top corners rounded by its own height) standing on the straight chord between two cities: the chord sits R·cos θ out from the centre and the radius is R·sin θ, where 2θ is the angle between the cities, so its feet land on both cities and the rest is off the ground. One transform, read right to left, places it: a lean about the chord (the feet stay put), rotateX(-90deg) to stand it up, rotateZ(ψ) to point it from one city to the other, rotateX(lat) rotateY(lon) to carry it to their midpoint. The numbers come from the cities' coordinates, worked out once.
  3. Why the lean: a flat arc seen edge-on is a broken hairline, and an upright arc is edge-on whenever its route faces you. Each arc is leant (27° to 53°) until its plane lies nearly level with the equator, and the camera looks down on the globe by 28°, more than any arc's plane is off level, so no arc ever turns edge-on. The routes are east–west ones, which is what makes that lean possible.
  4. The plane rides an arm pinned at the half circle's centre that turns 0° to 180°, so it follows the arc and always points along it. It is the same plane shape twice, crossed at 90°, so it never vanishes edge-on, and it fades in and out with opacity at the two airports.
  5. The globe turns once every 48 s about a tilted axis, so the routes go round the back and come again. The caption under it is flat chrome in the stage's own ink. Every length is a multiple of one base unit, --u, so the globe is the same share of a card, the editor and a recording canvas.

Key techniques

  • camera-facing disc hides the far half
  • half-circle arcs leant about their chord
  • rotateY · rotateX · rotateZ placement from coordinates
  • arm-carried plane along an arc

Copy-paste code

HTML 34 lines

<div class="band">
  <div class="view">
    <div class="world" role="img" aria-label="A turning globe with six flight routes arcing between ten cities on five continents, a plane flying each one">
      <div class="ocean"></div>
      <div class="globe">
        <i class="ring lat" style="--r:82;--y:-57"></i>
        <i class="ring lat" style="--r:100;--y:0"></i>
        <i class="ring lat" style="--r:82;--y:57"></i>
        <i class="ring mer" style="--m:0"></i>
        <i class="ring mer" style="--m:60"></i>
        <i class="ring mer" style="--m:120"></i>
        <i class="city" style="--lon:0;--lat:51"></i><!-- London -->
        <i class="city" style="--lon:-74;--lat:41"></i><!-- New York -->
        <i class="city" style="--lon:-118;--lat:34"></i><!-- Los Angeles -->
        <i class="city" style="--lon:140;--lat:36"></i><!-- Tokyo -->
        <i class="city" style="--lon:-47;--lat:-23"></i><!-- São Paulo -->
        <i class="city" style="--lon:28;--lat:-26"></i><!-- Johannesburg -->
        <i class="city" style="--lon:55;--lat:25"></i><!-- Dubai -->
        <i class="city" style="--lon:114;--lat:22"></i><!-- Hong Kong -->
        <i class="city" style="--lon:116;--lat:-32"></i><!-- Perth -->
        <i class="city" style="--lon:151;--lat:-34"></i><!-- Sydney -->
      <div class="route" style="--lon:-40.9;--lat:52.3;--psi:166.5;--a:42.4;--c:90.6;--lean:53;--col:#2ee6d6;--t:5s;--dl:-1.2s"><i class="arc"></i><b class="arm"><i></i><i></i></b></div><!-- London – New York -->
      <div class="route" style="--lon:-168.1;--lat:48;--psi:-178.1;--a:63.7;--c:77.1;--lean:48;--col:#4cc9ff;--t:6.6s;--dl:-4.1s"><i class="arc"></i><b class="arm"><i></i><i></i></b></div><!-- Los Angeles – Tokyo -->
      <div class="route" style="--lon:-97.1;--lat:39.6;--psi:-12;--a:30.3;--c:95.3;--lean:-40;--col:#8b6cff;--t:4s;--dl:-0.6s"><i class="arc"></i><b class="arm"><i></i><i></i></b></div><!-- Los Angeles – New York -->
      <div class="route" style="--lon:-10;--lat:-29.9;--psi:2.8;--a:55.4;--c:83.2;--lean:30;--col:#7ee787;--t:6s;--dl:-2.6s"><i class="arc"></i><b class="arm"><i></i><i></i></b></div><!-- São Paulo – Johannesburg -->
      <div class="route" style="--lon:84.9;--lat:26.5;--psi:3.4;--a:45.2;--c:89.2;--lean:-27;--col:#ffb547;--t:5.2s;--dl:-3.3s"><i class="arc"></i><b class="arm"><i></i><i></i></b></div><!-- Dubai – Hong Kong -->
      <div class="route" style="--lon:133.3;--lat:-34.3;--psi:4;--a:25.3;--c:96.8;--lean:34;--col:#ff4d9d;--t:3.8s;--dl:-1.9s"><i class="arc"></i><b class="arm"><i></i><i></i></b></div><!-- Perth – Sydney -->
      </div>
    </div>
  </div>
  <div class="controls">
    <p class="caption">6 routes · 1,240 flights this week</p>
  </div>
</div>

CSS 130 lines

/* the model box and the caption stand in one stack, centred together */
.band { display: grid; justify-items: center; gap: 4vmin; }
.view {
  /* one base unit: every length below is a multiple of it, so the globe is the same share of a
     gallery card, the editor, a full screen and a recording canvas */
  --u: 0.24vmin;
  height: 50vmin;
  display: grid;
  place-items: center;
  perspective: calc(1000 * var(--u));
}
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}
.controls .caption {
  margin: 0;
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  opacity: 0.7;
}

/* the globe: radius R = 100 */
.world {
  position: relative;
  width: calc(200 * var(--u));
  height: calc(200 * var(--u));
  transform-style: preserve-3d;
}

/* the body: a disc facing the camera at the sphere's middle. Whatever turns behind z = 0 inside
   its outline is hidden by it, which is all a sphere does */
.ocean {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: radial-gradient(circle at 36% 28%, #6c95ff, #2b4bd0 34%, #17228a 66%, #0b1150);
  box-shadow:
    inset calc(-10 * var(--u)) calc(-14 * var(--u)) calc(30 * var(--u)) rgb(4 6 30 / 0.55),
    0 0 calc(14 * var(--u)) rgb(76 201 255 / 0.45);
}

.globe {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  animation: spin 48s linear infinite;
}
/* everything on the globe is placed from its centre */
.globe * {
  position: absolute;
  left: 50%;
  top: 50%;
  box-sizing: border-box;
  transform-style: preserve-3d;
}

/* grid: three circles of latitude laid flat, three meridians stood on the axis */
.ring {
  --r: 100;
  width: calc(var(--r) * 2 * var(--u));
  height: calc(var(--r) * 2 * var(--u));
  margin: calc(var(--r) * -1 * var(--u)) 0 0 calc(var(--r) * -1 * var(--u));
  border: calc(1.2 * var(--u)) solid rgb(150 190 255 / 0.4);
  border-radius: 50%;
}
.lat { transform: translateY(calc(var(--y) * var(--u))) rotateX(90deg); }
.mer { transform: rotateY(calc(var(--m) * 1deg)); }

/* a city: a dot on the tangent plane, just off the ground */
.city {
  width: calc(9 * var(--u));
  height: calc(9 * var(--u));
  margin: calc(-4.5 * var(--u)) 0 0 calc(-4.5 * var(--u));
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 0 calc(2 * var(--u)) rgb(255 255 255 / 0.35);
  transform: rotateY(calc(var(--lon) * 1deg)) rotateX(calc(var(--lat) * 1deg)) translateZ(calc(101 * var(--u)));
}

/* a route: read right to left, lean the half circle about its chord (its feet stay on the
   cities, and leant it is a curve even seen from straight above), stand it up, point it from
   city to city, carry it to the midpoint of the two */
.route {
  transform:
    rotateY(calc(var(--lon) * 1deg)) rotateX(calc(var(--lat) * 1deg)) rotateZ(calc(var(--psi) * 1deg)) rotateX(-90deg)
    translateY(calc(var(--c) * -1 * var(--u))) rotateX(calc(var(--lean) * 1deg)) translateY(calc(var(--c) * var(--u)));
}
/* the half circle: its chord is c out from the centre, its radius a */
.arc {
  left: calc(var(--a) * -1 * var(--u));
  top: calc((var(--c) + var(--a)) * -1 * var(--u));
  width: calc(var(--a) * 2 * var(--u));
  height: calc(var(--a) * var(--u));
  border: calc(2.6 * var(--u)) solid var(--col);
  border-bottom: 0;
  border-radius: calc(var(--a) * var(--u)) calc(var(--a) * var(--u)) 0 0;
}
/* the arm turns about the half circle's centre, carrying the plane from one city to the other */
.arm {
  left: 0;
  top: calc(var(--c) * -1 * var(--u));
  animation: fly var(--t) linear var(--dl) infinite;
}
/* the plane, nose along the arc, drawn twice and crossed so it never goes edge-on */
.arm i {
  left: calc((var(--a) - 1) * -1 * var(--u) - 8 * var(--u));
  top: calc(-9 * var(--u));
  width: calc(16 * var(--u));
  height: calc(18 * var(--u));
  background: var(--col);
  clip-path: polygon(50% 0, 57% 9%, 57% 36%, 100% 58%, 100% 68%, 57% 56%, 56% 80%, 72% 92%, 72% 100%, 50% 95%, 28% 100%, 28% 92%, 44% 80%, 43% 56%, 0 68%, 0 58%, 43% 36%, 43% 9%);
  opacity: 0;
  animation: blink var(--t) linear var(--dl) infinite;
}
.arm i + i { transform: rotateY(90deg); }

@keyframes spin {
  from { transform: rotateZ(-14deg) rotateX(-28deg) rotateY(-50deg); }
  to   { transform: rotateZ(-14deg) rotateX(-28deg) rotateY(310deg); }
}
@keyframes fly {
  from { transform: rotate(0deg); }
  to   { transform: rotate(180deg); }
}
@keyframes blink {
  0%, 100% { opacity: 0; }
  8%, 92%  { opacity: 1; }
}
Standalone snippet · plain CSS, no build step, no dependencies.