Published just now • Last updated July 25, 2026 • ⏱️ 13 min read

A few years ago I built a first-person 3D world you can walk around, and I shipped it with a rule I refused to break: not a single line of JavaScript. No <script>, no javascript: URLs, no inline on* handlers. The entire world is HTML and CSS. The rooms, the doors, the walking, the turning, all of it. That's it.
I wrote about the original build in Creating a 3D world in pure CSS, and my reasoning then is still my reasoning now: limitations are fun. When you take away the obvious tool, you're forced to find a stranger, more interesting route. This post is about the route I took recently to give that world three things it had always been missing: proper first-person rotation, collision detection, and teleports. All of it, as always, in pure CSS.
I'll be honest up front. I didn't do this round alone. I paired with Claude Code and we designed the new functionality together. Some of the best moments were me shouting "that's unbelievable" at my screen while a checkbox and an if() statement did something they had no business doing.
If you've never seen it, garethheyes.co.uk drops you into a room. You walk forward with the arrow keys or the on-screen pad, you turn to look around, you open doors into other rooms, and the walls are hung with links to my research and talks. It's a portfolio you explore rather than scroll.
Underneath, the trick has always been the same handful of CSS primitives:
.container with perspective sets up the camera, and a .scene inside it with transform-style: preserve-3d becomes the world.rotateX/rotateY and pushed into place with translateZ.@keyframes that animate custom properties, and the animations are paused by default. Focusing an anchor (:focus) or targeting one via the URL hash (:target) flips a single animation to running, and the sibling combinator (~) pipes that state into the scene.<input type="checkbox"> plus <label>: click the door, the checkbox flips, and :checked swings it open and reveals the room behind.The whole engine ran on two variables:
.scene { transform: translateZ(var(--sceneZPos)) rotateY(var(--rotation)); }
One number for how far you'd walked, one angle for where you were looking. Two @keyframes drove --sceneZPos (forward and back), two drove --rotation (turn left and right), and pausing them at the right moment left you standing wherever you'd stopped. It's a lovely little machine. It also had a bug I'd been squinting at for years without ever pinning down.
Everything up to this point is the world as it stood before 22 July 2026. Everything after is the sprint.
The bug was this. Turn on the spot in the first room and everything's fine. But walk a few metres forward, then turn, and instead of pivoting on the spot like a person turning their head, the whole world swings around some point out in front of you. You lose your centre.
The cause is hiding in that transform. The rotation happens around transform-origin, which was pinned to the centre of the scene:
.scene { transform-origin: 50% 50% 0; }
The centre of the scene never moves. So the moment you translate away from it you stop spinning around yourself and start spinning around a fixed point in the world. Working the matrix through, translateZ(z) rotateY(r) around a static origin rotates the world about its centre and then shifts it. At z = 0 that's indistinguishable from turning your head. At z ≠ 0 it's a fairground waltzer.
My first instinct was to make the origin follow me by tracking -sceneZPos on the Z axis, so the pivot stays under my feet. And it works. The turn pivots in place again. But it introduces a new problem, and this is where it got interesting: once the origin tracks the translation, "forward" is baked into the world's Z axis. Turn 90° and press forward and you slide sideways.
You cannot have both. A single translateZ plus a single rotateY gives you two degrees of freedom, but a first-person camera needs three: X, Z, and a facing angle. I tried everything, including the new CSS trigonometry functions sin() and cos(), to decompose "forward" into world axes. Trig gets you the direction of the current step, but it can't remember the past steps. The moment you turn, your already-walked distance re-projects onto the new heading and you swing around the origin all over again. CSS has no way to commit a computed value back into a variable. That's exactly the sort of thing you'd reach for JavaScript to do, and I don't have any.
So I stopped trying to fix the continuous engine and rebuilt it. If continuous turning is the enemy, make turning discrete.
The new model faces one of four directions, north, east, south or west, held in a group of radio buttons:
<input class="facing" type="radio" name="facing" id="faceN" checked> <input class="facing" type="radio" name="facing" id="faceE"> <input class="facing" type="radio" name="facing" id="faceS"> <input class="facing" type="radio" name="facing" id="faceW">
Because the facing is now discrete and axis-aligned, I can wire "forward" to the correct world axis for each direction. And rather than one position variable, I use four monotonic accumulators, a "plus" and a "minus" half for each axis:
@property --posZp { syntax:'<length>'; inherits:false; initial-value:0px; } @property --posZn { syntax:'<length>'; inherits:false; initial-value:0px; } @property --posXp { syntax:'<length>'; inherits:false; initial-value:0px; } @property --posXn { syntax:'<length>'; inherits:false; initial-value:0px; }
Each half only ever grows. Your net position is posZp - posZn and posXp - posXn. The reason for the split is subtle but it's the whole ballgame. Because each half is tied to a fixed world axis and only ever counts up, your position never re-projects when you turn. That sideways-walk bug simply cannot happen.
Movement is then a matter of pairing the current facing with a movement control and running exactly one accumulator:
/* facing north + forward -> walk +Z */ #faceN:checked ~ :is(.moveForward:target, #moveForward:focus) ~ .cameraRot .scene { animation-play-state: running, paused, paused, paused; } /* facing east + forward -> walk -X */ #faceE:checked ~ :is(.moveForward:target, #moveForward:focus) ~ .cameraRot .scene { animation-play-state: paused, paused, paused, running; }
Rotation and translation get split across two nested layers, so the turn can ease smoothly while walking stays instant:
.cameraRot { transform: rotateY(var(--yaw, 0deg)); transition: transform 0.45s; } .cameraPos { transform: translate3d( calc(var(--posXp) - var(--posXn)), 0, calc(var(--posZp) - var(--posZn))); }
There's one last piece of black magic. The rotation pivot has to sit on the eye, not on the scene plane, and in a CSS perspective the eye sits one perspective-distance in front of z = 0. So the fix is to push the rotation origin forward by exactly that much:
.container { perspective: 6em; } .cameraRot { transform-origin: 50% 50% 6em; } /* pivot on the eye */
Now you turn on the spot, at any position, facing any direction, and forward always follows your nose. It cost me smooth free-look, because you turn in 90° snaps like a dungeon crawler, but grid-turning suits a corridor full of pictures and the trade was worth it.
This is the one that made me laugh out loud.
The world is an L-shape: a room, a long gallery corridor heading north, and a link room off to the west, joined by doorways. To stop you walking through walls I need to know where the walls are, notice when the camera crosses one, and stop it. In CSS. With no script.
My first reaction was that it couldn't be done, and I had reasons. clamp() can bound a value, but it only expresses the intersection of limits, never the union of two boxes. A single clamp can keep you inside the room or let you through the doorway, but not both. And detecting a wall crossing is a range comparison, "has my Z passed this line?", which CSS style queries couldn't do.
Then I remembered: Chrome now supports less-than and greater-than inside if().
That changes everything, because if() gives you branching. With real conditionals I can describe a non-convex walkable region piecewise: if I'm in the corridor's Z-range, narrow the X bounds, otherwise use the room's bounds. That was the whole objection, gone.
Here's the shape of it. The camera's position lives in registered custom properties so a style query can read it, and each door's open or closed state becomes a flag via :has():
@property --rawX { syntax:'<length>'; inherits:false; initial-value:0px; } @property --rawZ { syntax:'<length>'; inherits:false; initial-value:0px; } .scene:has(#doorState:checked) { --door1open: 1; } .scene:has(#doorState2:checked) { --door2open: 1; }
Then if() chooses which wall applies for the region you're standing in, and clamp() enforces it:
/* You may head north into the gallery only when you're inside the tube's X-band AND door 1 is open, otherwise you stop at the room's north wall. */ --z-upper: if( style(--door1open: 1) and style(--rawX >= -10em) and style(--rawX <= 10em): var(--wall-corridor-end); else: var(--wall-room-north)); transform: translate3d( clamp(var(--wall-east), var(--rawX), var(--x-upper)), 0, clamp(var(--wall-south), var(--rawZ), var(--z-upper)));
The east and back walls are shared by every region, so those are a plain clamp() that works in every browser. The branchy bits are the if() calls: the west opening into the link room, the north opening into the gallery. Where if() isn't supported they fall back to "no wall there", so the site degrades gracefully rather than breaking.
And the doors fall out of it for free. A closed door is just a wall the collision hasn't been told to remove. Open it and the flag flips, the if() picks the far bound, and you can walk through. You cannot pass a shut door because there is, in the most literal CSS sense, a wall in the way.
The first time I walked into a corner and stopped, with no script, just a checkbox, an if() and a clamp() conspiring, I couldn't believe it. This is collision detection, in a stylesheet.
The finishing touch was the burger-menu shortcuts: Home, Gallery, Link room. Jump straight to a room instead of walking. Easy, surely. Set an offset and you're there.
Nothing about it was easy, and every failure taught me something about the shape of the problem.
The naive version used the URL hash (:target) to apply an offset, but the hash is also how the movement controls work, so the instant you took a step the teleport evaporated and snapped you back. The next version tied each teleport to its door checkbox, which was lovely until you opened both doors: the two offsets stacked and collision clamped you into a corner. So teleporting to the link room and then the gallery quietly failed.
The fix was to make the three destinations mutually exclusive, which in CSS means one thing: a radio group.
<input class="tele" type="radio" name="tele" id="telHome" checked> <input class="tele" type="radio" name="tele" id="telGallery"> <input class="tele" type="radio" name="tele" id="telLinks">
Selecting one clears the others, so the offsets can never stack. Each non-home state offsets the camera into its room, flips that room's collision flag so the offset isn't clamped at the threshold, swings the door open, and for the westward link room rotates you to face it:
#telGallery:checked ~ .cameraRot .scene { --teleZ: 18em; --door1open: 1; } #telLinks:checked ~ .cameraRot .scene { --teleX: 18em; --door2open: 1; } #telLinks:checked ~ .cameraRot { --yaw: -90deg; } /* face west */
Two more problems, two more tricks.
I wanted them to glide, not jump. So I registered the teleport offsets as animatable properties and put a transition on just those properties. The walk accumulators aren't transitioned, so walking stays crisp while a teleport eases you in:
@property --teleX { syntax:'<length>'; inherits:false; initial-value:0px; } @property --teleZ { syntax:'<length>'; inherits:false; initial-value:0px; } .scene { transition: --teleX 0.9s ease, --teleZ 0.9s ease; }
And they had to be absolute. This one is my favourite hack of the lot. The offset only removed its own distance, so if you'd walked deep into the gallery and hit Home, you'd shuffle back 18em and still be standing in the gallery, because your walked distance was never reset. CSS can't reset an animation's value on demand, but it does restart an animation when its animation-name changes. So I gave each destination its own copy of the walk animations, identical but under a different name:
@keyframes accZpG { to { --posZp: 1000em; } } /* gallery's copy */ @keyframes accZpL { to { --posZp: 1000em; } } /* link room's copy */ #telGallery:checked ~ .cameraRot .scene { animation-name: accZpG, accZnG, accXpG, accXnG; } #telLinks:checked ~ .cameraRot .scene { animation-name: accZpL, accZnL, accXpL, accXnL; }
Switch destination, the names change, the animations restart from zero, and your walked distance evaporates. Teleports became absolute. Home goes home.
Constraints are fun because some of them win. A few things fought me to a draw.
The wrap seam. With four fixed facings, one of the four turns in a full circle has to spin the long way round. Avoiding it would mean storing an unbounded winding number, and CSS can't. So a 360° spin has exactly one moment where it whirls backward. I moved it somewhere you rarely look and made peace with it.
Locked facing in the link room. A single click can't both flip the facing radio and teleport, so while you're teleported into the link room your facing is pinned west. Pick another destination to turn away.
The instant recall. You can't animate an accumulator reset, so returning Home from the far end of the gallery has an instant snap-back before the glide. The destination is always right, the journey isn't always smooth.
I could make every one of these buttery with about ten lines of JavaScript. That's rather the point.
None of this needed a framework, a build-time renderer, or a canvas. It needed checkboxes, radio buttons, custom properties, :has(), clamp(), and the newest trick in the box, if() with range comparisons. The browser has quietly turned into a place where you can do collision detection and stateful navigation in a stylesheet, and almost nobody has noticed.
Go and have a walk around garethheyes.co.uk. Open the doors, bump into the walls, jump to a room. And remember, the whole time, there is no JavaScript.