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.
Layers sit at different translateZ depths; tilting the scene with the pointer makes them slide apart.
Your edited version
translateZ: moon far away (−300 units), mountains, hills, trees in front (+70 units).scale() to keep filling the frame: (perspective + depth) / perspective, so 1.6 for the moon at 300 units behind a 500-unit perspective.inset: -20%) so their edges never show while tilting. That also means a shape placed at 90% of a layer sits past the edge of the canvas, so the trees are placed by where they land on it: the tree layer is drawn about 1.47 times the canvas width, and its trees stand at 23–31% and 66.5–74.5% of it.inset: 0 and the layers are sized in percentages, so the scene fills the canvas edge to edge. Depths, the perspective and the moon are multiples of one base unit, --u, tied to the canvas, so the parallax is the same on a gallery card and on a full screen.translateZ per layerscale() to compensate for distancepointer → two rotation variables<div class="view" tabindex="0" role="img" aria-label="Layers in depth that turn toward the pointer">
<div class="world">
<i></i><i></i><i></i><i></i>
</div>
</div>
.view {
/* one base unit, tied to the canvas; the view itself fills the canvas edge to edge */
--u: 0.33vmin;
position: fixed;
inset: 0;
overflow: hidden;
perspective: calc(500 * var(--u));
background: linear-gradient(#0c1033, #3a1d5e 60%, #ff7a59);
}
.world {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
/* slow ease back to rest... */
transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* ...but nearly instant while the pointer is driving: a long transition restarts on every
pointer event and makes the scene lag behind the hand */
.world.is-live {
transition-duration: 0.1s;
}
.world i {
position: absolute;
inset: -20%;
}
/* moon */
.world i:nth-child(1) {
inset: 14% auto auto 62%;
width: calc(70 * var(--u));
height: calc(70 * var(--u));
border-radius: 50%;
background: #fff4d1;
box-shadow: 0 0 calc(40 * var(--u)) #fff4d1;
transform: translateZ(calc(-300 * var(--u))) scale(1.6);
}
/* far mountains */
.world i:nth-child(2) {
background: #4a2a73;
clip-path: polygon(0 100%, 0 62%, 18% 44%, 34% 60%, 52% 38%, 70% 58%, 86% 42%, 100% 60%, 100% 100%);
transform: translateZ(calc(-160 * var(--u))) scale(1.32);
}
/* near hills */
.world i:nth-child(3) {
background: #2a1648;
clip-path: polygon(0 100%, 0 70%, 22% 58%, 44% 72%, 66% 56%, 84% 70%, 100% 62%, 100% 100%);
transform: translateZ(calc(-40 * var(--u))) scale(1.08);
}
/* foreground trees. The layer is oversized (-20% all round) and stands nearer, so it is drawn
about 1.47 times wider than the canvas: a point at p% of it lands at 50 + 1.47 × (p − 50)% of
the canvas. The two trees stand at 23–31% and 66.5–74.5% of it, which puts them well inside
the canvas at every tilt instead of cut in half by its edges. */
.world i:nth-child(4) {
background: #0d0820;
clip-path: polygon(0 100%, 0 78%, 23% 78%, 27% 60%, 31% 78%, 66.5% 80%, 70.5% 58%, 74.5% 80%, 100% 80%, 100% 100%);
transform: translateZ(calc(70 * var(--u))) scale(0.9);
}
/* 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) */
.view:focus-visible .world { --rx: -5deg !important; --ry: 8deg !important; }
const view = document.querySelector('.view');
const world = document.querySelector('.world');
view.addEventListener('pointermove', (e) => {
const x = e.clientX / innerWidth - 0.5; // -0.5 … 0.5
const y = e.clientY / innerHeight - 0.5;
world.style.setProperty('--ry', x * 30 + 'deg');
world.style.setProperty('--rx', -y * 20 + 'deg');
world.classList.add('is-live');
});
view.addEventListener('pointerleave', () => {
world.classList.remove('is-live');
world.style.removeProperty('--rx');
world.style.removeProperty('--ry');
});