Solar system
Pure CSSOrbits are rings on a tilted plane, each spinning at its own speed. Every planet undoes both rotations, so it always faces the camera.
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.
Your edited version
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.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).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.--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.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.box faces turned inwardtransform-origin z = perspectivewalls extended toward the camerapointer → --rx / --ry<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>
.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; }
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');
});