Pure CSS Scenes & objects #loop #map #pin #billboard #scene

Map pins in pure CSS

A map drawn with gradients lies on a tilted plane. The pins undo the plane’s rotation around their tip, so they stand up and face you, then bounce in turn above a pulsing ring.

  • 132 lines of CSS
  • 9 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. The map is one element painted with layered gradients (land ellipses, a river cut from a big ring, two roads, a grid), tilted with rotateX(54deg) rotateZ(-14deg).
  2. Each pin anchor is a zero-size point on the map. Its pulse ring is a child of that point, so it stays flat in the ground plane and spreads as an ellipse.
  3. The pin undoes the plane in reverse order, rotateZ(14deg) rotateX(-54deg), around transform-origin: 50% 100%: its tip. It stands up exactly on its spot and faces you.
  4. Bounce keyframes repeat that inverse and add translateY and a squash scale after it, so the hop happens in screen space. animation-delay: calc(var(--i) * 0.8s) makes the pins take turns.

Key techniques

  • map from layered gradients
  • pin: inverse rotation, transform-origin bottom
  • ring stays in the ground plane
  • staggered animation-delay

Copy-paste code

HTML 9 lines

<div class="scene">
  <div class="map">
    <div class="plane">
      <i style="--x:24%;--y:34%;--c:#ff4d9d;--i:0"><b></b></i>
      <i style="--x:58%;--y:66%;--c:#ffb547;--i:1"><b></b></i>
      <i style="--x:80%;--y:28%;--c:#8b6cff;--i:2"><b></b></i>
    </div>
  </div>
</div>

CSS 132 lines

.scene {
  perspective: 800px;
}

.map {
  position: relative;
  width: 210px;
  height: 180px;
  transform-style: preserve-3d;
}

.plane {
  --land: #1c5a60;
  --water: #262a55;
  --road: rgb(255 181 71 / 0.6);
  position: absolute;
  left: 10px;
  top: 34px;
  width: 190px;
  height: 150px;
  border: 1px solid rgb(46 230 214 / 0.45);
  border-radius: 14px;
  background:
    linear-gradient(28deg, transparent calc(50% - 1.5px), var(--road) 0 calc(50% + 1.5px), transparent 0),
    linear-gradient(90deg, transparent 63%, var(--road) 0 calc(63% + 3px), transparent 0),
    radial-gradient(circle at 115% 125%, transparent 56%, var(--water) 0 59%, transparent 0),
    radial-gradient(ellipse 30% 25% at 26% 32%, var(--land) 98%, transparent),
    radial-gradient(ellipse 26% 22% at 66% 68%, var(--land) 98%, transparent),
    radial-gradient(ellipse 15% 13% at 82% 24%, var(--land) 98%, transparent),
    radial-gradient(ellipse 12% 10% at 26% 80%, var(--land) 98%, transparent),
    linear-gradient(90deg, rgb(236 238 251 / 0.07) 1px, transparent 1px) 0 0 / 19px 19px,
    linear-gradient(rgb(236 238 251 / 0.07) 1px, transparent 1px) 0 0 / 19px 19px,
    var(--water);
  transform-style: preserve-3d;
  transform: rotateX(54deg) rotateZ(-14deg);
}

/* the anchor: a point on the map, 1px above it */
.plane i {
  position: absolute;
  left: var(--x);
  top: var(--y);
  width: 0;
  height: 0;
  transform-style: preserve-3d;
  transform: translateZ(1px);
}

.plane i::before,
.plane i::after {
  content: '';
  position: absolute;
  border-radius: 50%;
  animation: 2.4s infinite;
  animation-delay: calc(var(--i) * 0.8s);
}

/* pulse ring, flat on the ground */
.plane i::before {
  top: -16px;
  left: -16px;
  width: 32px;
  height: 32px;
  border: 2px solid var(--c);
  box-sizing: border-box;
  opacity: 0;
  animation-name: ring;
  animation-timing-function: ease-out;
}

/* contact shadow */
.plane i::after {
  top: -4px;
  left: -7px;
  width: 14px;
  height: 8px;
  background: rgb(0 0 0 / 0.45);
  animation-name: shadow;
  animation-timing-function: ease-in-out;
}

/* the pin: tip at bottom centre = transform-origin */
.plane b {
  position: absolute;
  bottom: 0;
  left: -13px;
  width: 26px;
  height: 32px;
  transform-origin: 50% 100%;
  transform: rotateZ(14deg) rotateX(-54deg); /* the plane, undone */
  animation: bounce 2.4s ease-in-out infinite;
  animation-delay: calc(var(--i) * 0.8s);
}

/* head: three round corners, turned so the sharp one points down */
.plane b::before {
  content: '';
  position: absolute;
  width: 26px;
  height: 26px;
  border-radius: 50% 50% 50% 0;
  background: radial-gradient(circle at 70% 30%, color-mix(in srgb, var(--c) 40%, #fff), var(--c) 55%, color-mix(in srgb, var(--c) 60%, #000));
  transform: rotate(-45deg);
}

.plane b::after {
  content: '';
  position: absolute;
  top: 8px;
  left: 8px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #fff;
}

@keyframes bounce {
  0%, 36%, 100% { transform: rotateZ(14deg) rotateX(-54deg) translateY(0) scale(1, 1); }
  14%           { transform: rotateZ(14deg) rotateX(-54deg) translateY(-16px) scale(0.94, 1.06); }
  26%           { transform: rotateZ(14deg) rotateX(-54deg) translateY(0) scale(1.14, 0.86); }
}

@keyframes shadow {
  0%, 36%, 100% { opacity: 1; transform: scale(1); }
  14%           { opacity: 0.45; transform: scale(0.6); }
}

@keyframes ring {
  0%, 24%   { opacity: 0; transform: scale(0.2); }
  27%       { opacity: 1; transform: scale(0.3); }
  70%, 100% { opacity: 0; transform: scale(1.5); }
}

Sass source 177 lines

// The map lies on a tilted, slightly turned plane. Each pin anchor (<i>) is a zero-size point
// ON the map, so its pulse ring stays in the ground plane. The pin itself (<b>) undoes the
// plane's rotations in reverse order around its own tip, so it stands up and faces you.
$tilt: 54deg;
$turn: -14deg;
$stand: rotateZ(-$turn) rotateX(-$tilt); // the inverse of the plane
$beat: 2.4s; // one full round: each of the three pins bounces once

.d-map {
  position: relative;
  width: 210px;
  height: 180px;
  transform-style: preserve-3d;

  &__plane {
    --land: color-mix(in srgb, var(--accent-2) 34%, var(--surface-solid));
    --water: color-mix(in srgb, var(--accent) 24%, var(--surface-solid));
    --road: color-mix(in srgb, var(--warm) 60%, transparent);
    position: absolute;
    left: 10px;
    top: 34px;
    width: 190px;
    height: 150px;
    border: 1px solid color-mix(in srgb, var(--accent-2) 45%, transparent);
    border-radius: 14px;
    background:
      // roads
      linear-gradient(28deg, transparent calc(50% - 1.5px), var(--road) 0 calc(50% + 1.5px), transparent 0),
      linear-gradient(90deg, transparent 63%, var(--road) 0 calc(63% + 3px), transparent 0),
      // a river: a thin slice of a big ring
      radial-gradient(circle at 115% 125%, transparent 56%, var(--water) 0 59%, transparent 0),
      // land masses
      radial-gradient(ellipse 30% 25% at 26% 32%, var(--land) 98%, transparent),
      radial-gradient(ellipse 26% 22% at 66% 68%, var(--land) 98%, transparent),
      radial-gradient(ellipse 15% 13% at 82% 24%, var(--land) 98%, transparent),
      radial-gradient(ellipse 12% 10% at 26% 80%, var(--land) 98%, transparent),
      // a faint grid over the sea
      linear-gradient(90deg, color-mix(in srgb, var(--text) 7%, transparent) 1px, transparent 1px) 0 0 / 19px 19px,
      linear-gradient(color-mix(in srgb, var(--text) 7%, transparent) 1px, transparent 1px) 0 0 / 19px 19px,
      var(--water);
    box-shadow: 0 0 30px color-mix(in srgb, var(--accent-2) 20%, transparent);
    transform-style: preserve-3d;
    transform: rotateX($tilt) rotateZ($turn);

    // pin anchor: a point on the map, lifted 1px so its ring never fights the map surface
    i {
      position: absolute;
      left: var(--x);
      top: var(--y);
      width: 0;
      height: 0;
      transform-style: preserve-3d;
      transform: translateZ(1px);

      // pulse ring, flat on the ground
      &::before,
      &::after {
        content: '';
        position: absolute;
        border-radius: 50%;
      }

      &::before {
        top: -16px;
        left: -16px;
        width: 32px;
        height: 32px;
        border: 2px solid var(--c);
        opacity: 0;
        animation: d-map-ring $beat ease-out infinite;
        animation-delay: calc(var(--i) * #{$beat * 0.3333});
      }

      // contact shadow, shrinks while the pin is up
      &::after {
        top: -4px;
        left: -7px;
        width: 14px;
        height: 8px;
        background: rgb(0 0 0 / 0.45);
        animation: d-map-shadow $beat ease-in-out infinite;
        animation-delay: calc(var(--i) * #{$beat * 0.3333});
      }
    }

    // The pin. Its box sits on the anchor with the tip at bottom centre, which is also
    // the transform-origin, so the inverse rotation stands it up ON the point.
    b {
      position: absolute;
      bottom: 0;
      left: -13px;
      width: 26px;
      height: 32px;
      transform-origin: 50% 100%;
      transform: $stand;
      animation: d-map-bounce $beat ease-in-out infinite;
      animation-delay: calc(var(--i) * #{$beat * 0.3333});

      // head: a square with three round corners, turned 45deg so the sharp one points down
      &::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        width: 26px;
        height: 26px;
        border-radius: 50% 50% 50% 0;
        background: radial-gradient(circle at 70% 30%, color-mix(in srgb, var(--c) 40%, #fff), var(--c) 55%, color-mix(in srgb, var(--c) 60%, #000));
        box-shadow: 0 0 10px color-mix(in srgb, var(--c) 50%, transparent);
        transform: rotate(-45deg);
      }

      // the white dot
      &::after {
        content: '';
        position: absolute;
        top: 8px;
        left: 8px;
        width: 10px;
        height: 10px;
        border-radius: 50%;
        background: #fff;
      }
    }
  }
}

// Each pin: a quick hop at the start of its third of the beat, then rest.
// Rest (0% / 36% / 100%) is the plain standing transform, so the loop is seamless.
@keyframes d-map-bounce {
  0%,
  36%,
  100% {
    transform: $stand translateY(0) scale(1, 1);
  }

  14% {
    transform: $stand translateY(-16px) scale(0.94, 1.06);
  }

  26% {
    transform: $stand translateY(0) scale(1.14, 0.86);
  }
}

@keyframes d-map-shadow {
  0%,
  36%,
  100% {
    opacity: 1;
    transform: scale(1);
  }

  14% {
    opacity: 0.45;
    transform: scale(0.6);
  }
}

@keyframes d-map-ring {
  0%,
  24% {
    opacity: 0;
    transform: scale(0.2);
  }

  27% {
    opacity: 1;
    transform: scale(0.3);
  }

  70%,
  100% {
    opacity: 0;
    transform: scale(1.5);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.