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.

  • 97 lines of CSS
  • 22 lines of HTML
  • 19 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

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.
  6. Every length in the block is a multiple of one base unit, --u, tied to the canvas: the ground is 172 units square and one em of height is 17 of them, so the city is the same share of a gallery card, the editor and a recording canvas. The whole canvas stays the pointer’s field and the block stands in the middle of it. There is no hint line: the badge in the corner of the stage already says to move the pointer.

Key techniques

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

Copy-paste code

HTML 22 lines

<div class="city" tabindex="0" role="img" aria-label="Isometric city block that turns toward the pointer">
  <div class="view">
    <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>
  </div>
</div>

CSS 97 lines

/* the whole canvas is the pointer's field; the block itself stands in the model box */
.city {
  /* one base unit: every length in the block is a multiple of it, so the city is the same share
     of a gallery card, the editor, a full screen and a recording canvas */
  --u: 0.33vmin;
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  overflow: hidden;
}

/* the model box */
.view {
  height: 70vmin;
  display: grid;
  place-items: center;
  perspective: calc(900 * var(--u));
}

.world {
  box-sizing: content-box; /* its size is its inside, its edge outside it, on any page */
  --cell: calc(40 * var(--u)); /* grid pitch */
  --line: rgb(255 181 71 / 0.4);
  position: relative;
  width: calc(172 * var(--u));
  height: calc(172 * var(--u));
  font-size: calc(17 * var(--u)); /* 1em of building height */
  border: calc(1 * var(--u)) solid rgb(139 108 255 / 0.45);
  border-radius: calc(10 * var(--u));
  /* street centre lines, one per 40 units, in the gaps between the lots */
  background:
    repeating-linear-gradient(90deg, transparent 0 calc(5 * var(--u)), var(--line) calc(5 * var(--u)) calc(7 * var(--u)), transparent calc(7 * var(--u)) calc(40 * var(--u))),
    repeating-linear-gradient(transparent 0 calc(5 * var(--u)), var(--line) calc(5 * var(--u)) calc(7 * var(--u)), transparent calc(7 * var(--u)) calc(40 * var(--u))),
    #1f1f40;
  transform-style: preserve-3d;
  transform: translateY(calc(8 * var(--u))) 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(calc(12 * var(--u)) + var(--x) * var(--cell));
  top: calc(calc(12 * var(--u)) + var(--y) * var(--cell));
  width: calc(28 * var(--u));
  height: calc(28 * var(--u));
  background: color-mix(in srgb, var(--c) 62%, #141830);
  box-shadow: inset 0 0 0 calc(3 * var(--u)) 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 calc(4 * var(--u)), transparent calc(4 * var(--u)) calc(8 * var(--u))),
    repeating-linear-gradient(90deg, var(--wall) 0 calc(4 * var(--u)), var(--lit) calc(4 * var(--u)) calc(8 * var(--u)));
  background-position: 0 calc(2 * var(--u)), calc(2 * var(--u)) 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 calc(4 * var(--u)), transparent calc(4 * var(--u)) calc(8 * var(--u))),
    repeating-linear-gradient(var(--wall) 0 calc(4 * var(--u)), var(--lit) calc(4 * var(--u)) calc(8 * var(--u)));
  background-position: 0 0, calc(2 * var(--u)) 0, 0 calc(2 * var(--u));
  transform-origin: left;
  transform: rotateY(90deg);
}

/* the keyboard: Tab to it and it leans as it would for a pointer near the top right, even with
   the pointer resting on the canvas (!important beats the pose the script writes inline) */
.city:focus-visible .world { --rx: -4deg !important; --rz: -10deg !important; }

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');
});
Standalone snippet · plain CSS, no build step, no dependencies.