CSS + JS Scenes & objects #pointer #scene #interior

Look around a room in CSS + JavaScript

A box seen from the inside: floor, ceiling and three walls. The pointer rotates it around the camera position, so it feels like turning your head.

  • 200 lines of CSS
  • 12 lines of HTML
  • 19 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Build a box around the camera: a back wall pushed away with translateZ, and side walls, floor and ceiling folded 90° from the edges of the view. Each is turned so its front faces into the room.
  2. The folds start 110 units in front of the screen (translateZ) and run 460 units deep. That extra length toward the camera is what you see when you turn your head; stop well short of the perspective distance (300 units).
  3. The key line: a transform-origin 300 units in front of the screen, the same as the perspective. The box then rotates around the viewer’s eye, which reads as looking around, not as the room swinging.
  4. JS maps the pointer to --rx / --ry of a few degrees. Everything on the walls (window, poster, door, rug, lamp) is a flat child of its wall, drawn with gradients.
  5. The room is inset: 0 and its walls are percentages of the canvas, so it fills the canvas edge to edge whatever its shape. Its depth and details are multiples of one base unit, --u, tied to the canvas. The hint is the standard caption, in plain vmin, where the control zone sits in every model.

Key techniques

  • box faces turned inward
  • transform-origin z = perspective
  • walls extended toward the camera
  • pointer → --rx / --ry

Copy-paste code

HTML 12 lines

<div class="room" tabindex="0" role="img" aria-label="A room seen from inside: the view turns with the pointer">
  <div class="box">
    <div class="wall back"><i class="window"></i><i class="picture"></i></div>
    <div class="wall left"><i class="poster"></i></div>
    <div class="wall right"><i class="door"></i></div>
    <div class="wall floor"><i class="light"></i><i class="rug"></i></div>
    <div class="wall ceil"><i class="lamp"></i></div>
  </div>
  <div class="controls">
    <output class="caption">Move your pointer</output>
  </div>
</div>

CSS 200 lines

.room {
  /* one base unit, tied to the canvas; the room itself fills the canvas edge to edge, its walls
     in percentages of it and its depth in units */
  --u: 0.33vmin;
  position: fixed;
  inset: 0;
  overflow: hidden;
  perspective: calc(300 * var(--u));
  background: #06070d;
}

.room * {
  box-sizing: border-box;
}

.box {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform-origin: 50% 50% calc(300 * var(--u)); /* rotate around the camera = turn your head */
  transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.box.is-live {
  transition-duration: 0.12s;
}

.wall {
  position: absolute;
  background-color: #28244a;
}

.wall > i {
  position: absolute;
}

/* 110 units (near end) - 460 units (depth) = -350 units */
.back {
  inset: 0;
  background-image: radial-gradient(ellipse 70% 60% at 50% 0%, rgb(255 181 71 / 0.2), transparent 70%);
  transform: translateZ(calc(-350 * var(--u)));
}

.left,
.right {
  top: 0;
  width: calc(460 * var(--u));
  height: 100%;
}

.left {
  left: 0;
  background-image: linear-gradient(90deg, rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.08));
  transform-origin: left;
  transform: translateZ(calc(110 * var(--u))) rotateY(90deg);
}

.right {
  right: 0;
  background-image: linear-gradient(270deg, rgb(0 0 0 / 0.4), rgb(0 0 0 / 0.12));
  transform-origin: right;
  transform: translateZ(calc(110 * var(--u))) rotateY(-90deg);
}

.floor,
.ceil {
  left: 0;
  width: 100%;
  height: calc(460 * var(--u));
}

.floor {
  bottom: 0;
  background:
    linear-gradient(transparent 30%, rgb(0 0 0 / 0.45)),
    repeating-linear-gradient(90deg, transparent 0 calc(30 * var(--u)), rgb(0 0 0 / 0.3) calc(30 * var(--u)) calc(32 * var(--u))),
    #6e4f2e;
  transform-origin: bottom;
  transform: translateZ(calc(110 * var(--u))) rotateX(90deg);
}

.ceil {
  top: 0;
  background-image: linear-gradient(rgb(0 0 0 / 0.4), rgb(0 0 0 / 0.1));
  transform-origin: top;
  transform: translateZ(calc(110 * var(--u))) rotateX(-90deg);
}

/* back wall */
.window {
  top: 20%;
  left: 16%;
  width: 30%;
  height: 40%;
  border: calc(5 * var(--u)) solid #948fb0;
  border-radius: calc(3 * var(--u));
  background:
    linear-gradient(90deg, transparent calc(50% - calc(2 * var(--u))), #948fb0 0 calc(50% + calc(2 * var(--u))), transparent 0),
    linear-gradient(transparent calc(50% - calc(2 * var(--u))), #948fb0 0 calc(50% + calc(2 * var(--u))), transparent 0),
    radial-gradient(circle at 72% 28%, #fff7da 0 9%, rgb(255 247 218 / 0.3) 10%, transparent 24%),
    radial-gradient(circle at 20% 30%, #fff 0 calc(1 * var(--u)), transparent calc(2 * var(--u))),
    radial-gradient(circle at 36% 70%, #fff 0 calc(1 * var(--u)), transparent calc(2 * var(--u))),
    linear-gradient(#070b24, #2b2466);
}

.picture {
  top: 24%;
  left: 60%;
  width: 22%;
  height: 28%;
  border: calc(4 * var(--u)) solid #ffb547;
  background: linear-gradient(160deg, #2ee6d6, #8b6cff 55%, #ff4d9d);
}

/* left wall: its left end is near the camera */
.poster {
  top: 22%;
  left: 50%;
  width: 22%;
  height: 38%;
  background:
    radial-gradient(circle at 50% 38%, #ffb547 0 18%, transparent 19%),
    linear-gradient(transparent 62%, #ff4d9d 62% 70%, transparent 70%),
    linear-gradient(#8b6cff, #463680);
}

/* right wall: its RIGHT end is near the camera */
.door {
  bottom: 0;
  left: 14%;
  width: 18%;
  height: 64%;
  border: calc(4 * var(--u)) solid #4a3220;
  border-bottom: 0;
  background:
    radial-gradient(circle at 18% 54%, #ffb547 0 calc(3 * var(--u)), transparent calc(4 * var(--u))),
    #7e5a33;
}

/* floor: its top edge touches the back wall */
.light {
  top: 4%;
  left: 22%;
  width: 30%;
  height: 34%;
  background: linear-gradient(rgb(223 230 255 / 0.22), transparent);
  clip-path: polygon(8% 0, 92% 0, 100% 100%, 0 100%);
}

.rug {
  top: 30%;
  left: 24%;
  width: 52%;
  height: 34%;
  border-radius: 50%;
  background: repeating-radial-gradient(ellipse, #ff4d9d 0 calc(8 * var(--u)), #463680 calc(8 * var(--u)) calc(16 * var(--u)), #2ee6d6 calc(16 * var(--u)) calc(19 * var(--u)), #463680 calc(19 * var(--u)) calc(26 * var(--u)));
  opacity: 0.85;
}

/* ceiling: its top edge is near the camera */
.lamp {
  top: 50%;
  left: calc(50% - calc(45 * var(--u)));
  width: calc(90 * var(--u));
  height: calc(90 * var(--u));
  border-radius: 50%;
  background: radial-gradient(circle, #fffaf0 0 16%, rgb(255 181 71 / 0.55) 20%, transparent 68%);
}

/* the control zone, flat and outside the 3D world, where the centred stack puts it in every
   model: a caption alone is one 5.4vmin line, so the stack is 50 + 4 + 5.4vmin and the zone's
   top is (50 + 4 - 5.4) / 2 = 24.3vmin below the middle of the canvas. The caption fades out
   with opacity, so it moves nothing */
.controls {
  position: absolute;
  top: calc(50% + 24.3vmin);
  left: 0;
  right: 0;
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
  pointer-events: none;
}

.controls .caption {
  color: #fff;
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  opacity: 0.7;
  transition: opacity 0.4s;
}

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

/* 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) */
.room:focus-visible .box { --rx: -3deg !important; --ry: 5deg !important; }

JS 19 lines

const view = document.querySelector('.room');
const target = view.querySelector('.box');

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('--ry', x * 16 + 'deg');
  target.style.setProperty('--rx', -y * 10 + '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.