CSS + JS Scenes & objects #pointer #isometric #scene #building

Isometric city in CSS + JavaScript

Sixteen buildings on a ground plane. Each one is a single element: the roof is lifted by translateZ and two pseudo-element walls hang down from it. The pointer turns the block.

  • 96 lines of CSS
  • 21 lines of HTML
  • 19 lines of JS
  • No dependencies
  • MIT licensed

How it works

  1. The ground is tilted into an isometric view: rotateX(58deg) rotateZ(45deg). Every building is placed on it with left/top from --x/--y.
  2. A building is one element. The element itself is the roof, lifted with translateZ(var(--h)). Heights are in em, so one font-size scales the whole skyline.
  3. ::before hangs from the roof’s bottom edge (transform-origin: top; rotateX(-90deg)) and ::after from its right edge (transform-origin: left; rotateY(90deg)). With a 45° turn those are exactly the two walls you can see; the hidden two are never built.
  4. Windows are two layered repeating gradients: opaque wall bands across, over lit/unlit columns. The side wall’s own x axis points down the building, so its pattern is turned 90°.
  5. JS only turns the pointer into --rx / --rz; a transition eases the block there, fast while the pointer moves and slow on the way back.

Key techniques

  • rotateX + rotateZ isometric view
  • roof translateZ, walls on ::before / ::after
  • windows from layered gradients
  • pointer → --rx / --rz

Copy-paste code

HTML 21 lines

<div class="city">
  <div class="world">
    <i style="--x:0;--y:0;--h:3.4;--c:#8b6cff"></i>
    <i style="--x:1;--y:0;--h:2.6;--c:#2ee6d6"></i>
    <i style="--x:2;--y:0;--h:3;--c:#ff4d9d"></i>
    <i style="--x:3;--y:0;--h:1.8;--c:#8b6cff"></i>
    <i style="--x:0;--y:1;--h:2.4;--c:#ffb547"></i>
    <i style="--x:1;--y:1;--h:1.2;--c:#8b6cff"></i>
    <i style="--x:2;--y:1;--h:2.2;--c:#2ee6d6"></i>
    <i style="--x:3;--y:1;--h:1.4;--c:#ff4d9d"></i>
    <i style="--x:0;--y:2;--h:2.8;--c:#8b6cff"></i>
    <i style="--x:1;--y:2;--h:1.8;--c:#2ee6d6"></i>
    <i style="--x:2;--y:2;--h:0.9;--c:#ff4d9d"></i>
    <i style="--x:3;--y:2;--h:1.6;--c:#8b6cff"></i>
    <i style="--x:0;--y:3;--h:1.5;--c:#ffb547"></i>
    <i style="--x:1;--y:3;--h:1.1;--c:#8b6cff"></i>
    <i style="--x:2;--y:3;--h:1.3;--c:#2ee6d6"></i>
    <i style="--x:3;--y:3;--h:0.6;--c:#ff4d9d"></i>
  </div>
  <b class="hint">Move your pointer</b>
</div>

CSS 96 lines

.city {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  overflow: hidden;
  perspective: 900px;
}

.world {
  --cell: 40px; /* grid pitch */
  --line: rgb(255 181 71 / 0.4);
  position: relative;
  width: 172px;
  height: 172px;
  font-size: 17px; /* 1em of building height */
  border: 1px solid rgb(139 108 255 / 0.45);
  border-radius: 10px;
  /* street centre lines, one per 40px, in the gaps between the lots */
  background:
    repeating-linear-gradient(90deg, transparent 0 5px, var(--line) 5px 7px, transparent 7px 40px),
    repeating-linear-gradient(transparent 0 5px, var(--line) 5px 7px, transparent 7px 40px),
    #1f1f40;
  transform-style: preserve-3d;
  transform: translateY(26px) rotateX(calc(58deg + var(--rx, 0deg))) rotateZ(calc(45deg + var(--rz, 0deg)));
  transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.world.is-live {
  transition-duration: 0.1s;
}

/* the roof */
.world i {
  --wall: color-mix(in srgb, var(--c) 42%, #141830);
  --lit: #ffd89a;
  position: absolute;
  left: calc(12px + var(--x) * var(--cell));
  top: calc(12px + var(--y) * var(--cell));
  width: 28px;
  height: 28px;
  background: color-mix(in srgb, var(--c) 62%, #141830);
  box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--c) 40%, #141830);
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--h) * 1em));
}

.world i::before,
.world i::after {
  content: '';
  position: absolute;
}

/* front wall: hangs down from the bottom edge */
.world i::before {
  top: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--h) * 1em);
  background:
    repeating-linear-gradient(var(--wall) 0 4px, transparent 4px 8px),
    repeating-linear-gradient(90deg, var(--wall) 0 4px, var(--lit) 4px 8px);
  background-position: 0 2px, 2px 0;
  transform-origin: top;
  transform: rotateX(-90deg);
}

/* side wall: hangs down from the right edge, pattern turned, a shade darker */
.world i::after {
  top: 0;
  left: 100%;
  width: calc(var(--h) * 1em);
  height: 100%;
  background:
    linear-gradient(rgb(0 0 0 / 0.28), rgb(0 0 0 / 0.28)),
    repeating-linear-gradient(90deg, var(--wall) 0 4px, transparent 4px 8px),
    repeating-linear-gradient(var(--wall) 0 4px, var(--lit) 4px 8px);
  background-position: 0 0, 2px 0, 0 2px;
  transform-origin: left;
  transform: rotateY(90deg);
}

/* the hint is a flat overlay, outside the 3D world */
.hint {
  position: absolute;
  inset: auto 0 12px;
  color: #949bc0;
  font: 600 13px system-ui;
  text-align: center;
  pointer-events: none;
  transition: opacity 0.4s;
}

.is-touched .hint {
  opacity: 0;
}

JS 19 lines

const view = document.querySelector('.city');
const target = view.querySelector('.world');

view.addEventListener('pointermove', (e) => {
  const r = view.getBoundingClientRect();
  const x = (e.clientX - r.left) / r.width - 0.5;   // -0.5 … 0.5
  const y = (e.clientY - r.top) / r.height - 0.5;
  target.style.setProperty('--rz', -x * 40 + 'deg');
  target.style.setProperty('--rx', -y * 16 + 'deg');
  target.classList.add('is-live');
  view.classList.add('is-touched');
});

view.addEventListener('pointerleave', () => {
  target.classList.remove('is-live');
  target.style.removeProperty('--rx');
  target.style.removeProperty('--ry');
  target.style.removeProperty('--rz');
});

Sass source 111 lines

// Isometric block of 16 buildings. One element per building:
//   the element itself = the roof, lifted by translateZ(height)
//   ::before = the wall hanging down from its +y edge (rotateX(-90deg) around that edge)
//   ::after  = the wall hanging down from its +x edge (rotateY(90deg) around that edge)
// With rotateZ(45deg) those two walls are the ones that face the camera.
// Heights are in em (--h), so the font-size scales every building at once.
$cell: 40px; // grid pitch
$lot: 28px; // building footprint
$pad: 12px; // ground margin around the block

.d-city {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  overflow: hidden;
  perspective: 900px;

  &__world {
    position: relative;
    width: $cell * 4 + $pad * 2 - ($cell - $lot);
    height: $cell * 4 + $pad * 2 - ($cell - $lot);
    font-size: 17px;
    border-radius: 10px;
    // Street centre lines. Lots start at $pad and repeat every $cell, so a line at
    // ($pad - 6px) mod $cell lands in the middle of every street, and around the block.
    --line: color-mix(in srgb, var(--warm) 40%, transparent);
    background:
      repeating-linear-gradient(90deg, transparent 0 5px, var(--line) 5px 7px, transparent 7px $cell),
      repeating-linear-gradient(transparent 0 5px, var(--line) 5px 7px, transparent 7px $cell),
      color-mix(in srgb, var(--accent) 16%, var(--surface-solid));
    border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
    transform-style: preserve-3d;
    // translateY first: the buildings rise upward, so the block sits a bit low in the frame
    transform: translateY(26px) rotateX(calc(58deg + var(--rx, 0deg))) rotateZ(calc(45deg + var(--rz, 0deg)));
    transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);

    // while the pointer drives, follow it almost instantly
    &.is-live {
      transition-duration: 0.1s;
    }
  }

  // the roof
  i {
    --wall: color-mix(in srgb, var(--c) 42%, var(--surface-solid));
    --lit: color-mix(in srgb, var(--warm) 75%, #fff);
    position: absolute;
    left: calc(#{$pad} + var(--x) * #{$cell});
    top: calc(#{$pad} + var(--y) * #{$cell});
    width: $lot;
    height: $lot;
    background: color-mix(in srgb, var(--c) 62%, var(--surface-solid));
    box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--c) 40%, var(--surface-solid));
    transform-style: preserve-3d;
    transform: translateZ(calc(var(--h) * 1em));

    // Windows: horizontal wall bands (top layer) over vertical window columns (bottom layer).
    &::before,
    &::after {
      content: '';
      position: absolute;
    }

    // +y wall: hangs from the bottom edge
    &::before {
      top: 100%;
      left: 0;
      width: 100%;
      height: calc(var(--h) * 1em);
      background:
        repeating-linear-gradient(var(--wall) 0 4px, transparent 4px 8px),
        repeating-linear-gradient(90deg, var(--wall) 0 4px, var(--lit) 4px 8px);
      background-position: 0 2px, 2px 0;
      transform-origin: top;
      transform: rotateX(-90deg);
    }

    // +x wall: hangs from the right edge. Its own x axis now points DOWN the building,
    // so the window pattern is turned by 90deg. It gets a shade darker.
    &::after {
      top: 0;
      left: 100%;
      width: calc(var(--h) * 1em);
      height: 100%;
      background:
        linear-gradient(rgb(0 0 0 / 0.28), rgb(0 0 0 / 0.28)),
        repeating-linear-gradient(90deg, var(--wall) 0 4px, transparent 4px 8px),
        repeating-linear-gradient(var(--wall) 0 4px, var(--lit) 4px 8px);
      background-position: 0 0, 2px 0, 0 2px;
      transform-origin: left;
      transform: rotateY(90deg);
    }
  }

  // The hint is a flat overlay, outside the tilted world.
  b {
    position: absolute;
    inset: auto 0 12px;
    color: var(--muted);
    font-size: 13px;
    font-weight: 600;
    text-align: center;
    pointer-events: none;
    transition: opacity 0.4s;
  }

  &.is-touched b {
    opacity: 0;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.