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.

  • 181 lines of CSS
  • 10 lines of HTML
  • 19 lines of JS
  • No dependencies
  • MIT licensed

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 at translateZ(110px), in front of the screen, and run 460px deep. That extra length toward the camera is what you see when you turn your head; stop well short of the perspective distance (300px).
  3. The key line: transform-origin: 50% 50% 300px, 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.

Key techniques

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

Copy-paste code

HTML 10 lines

<div class="room">
  <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>
  <b class="hint">Move your pointer</b>
</div>

CSS 181 lines

.room {
  position: fixed;
  inset: 0;
  overflow: hidden;
  perspective: 300px;
  background: #06070d;
}

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

.box {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform-origin: 50% 50% 300px; /* 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;
}

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

.left,
.right {
  top: 0;
  width: 460px;
  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(110px) 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(110px) rotateY(-90deg);
}

.floor,
.ceil {
  left: 0;
  width: 100%;
  height: 460px;
}

.floor {
  bottom: 0;
  background:
    linear-gradient(transparent 30%, rgb(0 0 0 / 0.45)),
    repeating-linear-gradient(90deg, transparent 0 30px, rgb(0 0 0 / 0.3) 30px 32px),
    #6e4f2e;
  transform-origin: bottom;
  transform: translateZ(110px) 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(110px) rotateX(-90deg);
}

/* back wall */
.window {
  top: 20%;
  left: 16%;
  width: 30%;
  height: 40%;
  border: 5px solid #948fb0;
  border-radius: 3px;
  background:
    linear-gradient(90deg, transparent calc(50% - 2px), #948fb0 0 calc(50% + 2px), transparent 0),
    linear-gradient(transparent calc(50% - 2px), #948fb0 0 calc(50% + 2px), 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 1px, transparent 2px),
    radial-gradient(circle at 36% 70%, #fff 0 1px, transparent 2px),
    linear-gradient(#070b24, #2b2466);
}

.picture {
  top: 24%;
  left: 60%;
  width: 22%;
  height: 28%;
  border: 4px 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: 4px solid #4a3220;
  border-bottom: 0;
  background:
    radial-gradient(circle at 18% 54%, #ffb547 0 3px, transparent 4px),
    #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 8px, #463680 8px 16px, #2ee6d6 16px 19px, #463680 19px 26px);
  opacity: 0.85;
}

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

/* the hint is a flat overlay, outside the 3D world */
.hint {
  position: absolute;
  inset: auto 0 12px;
  color: #fff;
  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('.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');
});

Sass source 204 lines

// A box seen from the inside. The box fills the stage; every face is turned so its front
// looks INTO the room. The side walls, floor and ceiling are $depth long and start $front
// in front of the screen plane, so turning the head never reveals their near edges.
// The box rotates around the camera itself (transform-origin z = perspective).
$persp: 300px;
$depth: 460px;
$front: 110px; // stays well below $persp: nothing may reach the camera

.d-room {
  position: absolute;
  inset: 0;
  overflow: hidden;
  perspective: $persp;
  background: #06070d;

  &__box {
    --wall: color-mix(in srgb, var(--accent) 16%, var(--surface-solid));
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    transform-origin: 50% 50% $persp;
    transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
    transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);

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

  &__wall {
    position: absolute;
    background-color: var(--wall);

    > i {
      position: absolute;
    }

    // far wall: the whole stage, pushed back
    &--back {
      inset: 0;
      background-image: radial-gradient(ellipse 70% 60% at 50% 0%, color-mix(in srgb, var(--warm) 22%, transparent), transparent 70%);
      transform: translateZ($front - $depth);
    }

    // side walls: element's left/right edge is the near end, the other end is at the back wall
    &--left,
    &--right {
      top: 0;
      width: $depth;
      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($front) 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($front) rotateY(-90deg);
    }

    &--floor,
    &--ceil {
      left: 0;
      width: 100%;
      height: $depth;
    }

    // floor: wooden boards running toward the back wall, darker near the camera
    &--floor {
      bottom: 0;
      background:
        linear-gradient(transparent 30%, rgb(0 0 0 / 0.45)),
        repeating-linear-gradient(90deg, transparent 0 30px, rgb(0 0 0 / 0.3) 30px 32px),
        color-mix(in srgb, var(--warm) 34%, #2a1c12);
      transform-origin: bottom;
      transform: translateZ($front) 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($front) rotateX(-90deg);
    }
  }

  // ---- back wall ----
  // night window: moon, a few stars, a frame and a cross bar
  &__window {
    top: 20%;
    left: 16%;
    width: 30%;
    height: 40%;
    border: 5px solid color-mix(in srgb, var(--wall) 60%, #fff);
    border-radius: 3px;
    background:
      linear-gradient(90deg, transparent calc(50% - 2px), color-mix(in srgb, var(--wall) 60%, #fff) 0 calc(50% + 2px), transparent 0),
      linear-gradient(transparent calc(50% - 2px), color-mix(in srgb, var(--wall) 60%, #fff) 0 calc(50% + 2px), transparent 0),
      radial-gradient(circle at 72% 28%, #fff7da 0 9%, color-mix(in srgb, #fff7da 30%, transparent) 10%, transparent 24%),
      radial-gradient(circle at 20% 30%, #fff 0 1px, transparent 2px),
      radial-gradient(circle at 36% 70%, #fff 0 1px, transparent 2px),
      radial-gradient(circle at 84% 76%, #fff 0 1px, transparent 2px),
      linear-gradient(#070b24, color-mix(in srgb, var(--accent) 40%, #0b1030));
  }

  &__picture {
    top: 24%;
    left: 60%;
    width: 22%;
    height: 28%;
    border: 4px solid var(--warm);
    background:
      radial-gradient(circle at 68% 34%, #fff2 0 12%, transparent 13%),
      linear-gradient(160deg, var(--accent-2), var(--accent) 55%, var(--hot));
    box-shadow: 0 6px 14px rgb(0 0 0 / 0.35);
  }

  // ---- left wall (its left end is near the camera) ----
  &__poster {
    top: 22%;
    left: 50%;
    width: 22%;
    height: 38%;
    border-radius: 2px;
    background:
      radial-gradient(circle at 50% 38%, var(--warm) 0 18%, transparent 19%),
      linear-gradient(transparent 62%, var(--hot) 62% 70%, transparent 70%),
      linear-gradient(var(--accent), color-mix(in srgb, var(--accent) 50%, #000));
  }

  // ---- right wall (its RIGHT end is near the camera, so the far end is at left: 0) ----
  &__door {
    bottom: 0;
    left: 14%;
    width: 18%;
    height: 64%;
    border: 4px solid color-mix(in srgb, var(--warm) 30%, #1b120b);
    border-bottom: 0;
    background:
      radial-gradient(circle at 18% 54%, var(--warm) 0 3px, transparent 4px),
      linear-gradient(transparent 12%, rgb(0 0 0 / 0.18) 12% 44%, transparent 44% 52%, rgb(0 0 0 / 0.18) 52% 90%, transparent 90%),
      color-mix(in srgb, var(--warm) 40%, #3b2616);
  }

  // ---- floor (its top edge is at the back wall) ----
  // moonlight falling through the window
  &__light {
    top: 4%;
    left: 22%;
    width: 30%;
    height: 34%;
    background: linear-gradient(color-mix(in srgb, #dfe6ff 22%, transparent), 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,
      var(--hot) 0 8px,
      color-mix(in srgb, var(--accent) 70%, #000) 8px 16px,
      var(--accent-2) 16px 19px,
      color-mix(in srgb, var(--accent) 70%, #000) 19px 26px
    );
    opacity: 0.85;
  }

  // ---- ceiling (its top edge is near the camera) ----
  &__lamp {
    top: 50%;
    left: calc(50% - 45px);
    width: 90px;
    height: 90px;
    border-radius: 50%;
    background: radial-gradient(circle, #fffaf0 0 16%, color-mix(in srgb, var(--warm) 55%, transparent) 20%, transparent 68%);
  }

  // The hint is a flat overlay, outside the rotating box.
  b {
    position: absolute;
    inset: auto 0 12px;
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    text-align: center;
    text-shadow: 0 1px 6px rgb(0 0 0 / 0.7);
    pointer-events: none;
    transition: opacity 0.4s;
  }

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