Migrating a 2D HTML/CSS Layout to a 3D Spatial Web Interface
Migrating a 2D HTML/CSS layout to a 3D Spatial Web Interface translates the page's structure, not its markup: every structural concept in a flat page has a named spatial equivalent, and your CSS grid rules keep doing the layout work. What changes is what gets laid out (3D elements recorded in a scene document, not DOM nodes), the medium the solved layout is expressed in (world space instead of the viewport), and the vocabulary you use to name its parts. This page walks the mapping using the Software 4 Senses 3D Spatial Web Studio as the reference implementation.
Keep your mental model
The anatomy of a spatial site maps one-to-one onto the anatomy of the 2D page you already have, so the first step of a migration is renaming, not redesigning. The hierarchy runs site → stage → section → rig → slab, and each level corresponds to a structure you have been authoring for years. Structurally, the rigs and slabs you migrate into are 3D document elements in the Scene Document Model — addressable, animatable, serialized with the scene. A section becomes a detent on the scene's saved rail, and stage and passage are design vocabulary with no record of their own:
| 2D concept | Spatial equivalent | What it is |
|---|---|---|
| Page | Stage | One continuous camera-space experience. In the design vocabulary, a URL routes to a stage plus a pose within it, rather than fetching a new document. |
<section> element | Section | A snap point (detent) on the stage's scroll rail — one viewport-full of intent. Scrolling advances along the rail from section to section, just as it advances down a page. |
| Flex/grid container | Rig | An arrangement machine that positions slabs in the volume. Four rig types ship: plane (the flat-page baseline), shelf (rows at distinct depths), ring (slabs on an arc), and stack (a sequence along z) — and a rig can animate between them. |
<div> / card | Slab | The atomic content-bearing volume. The load-bearing idea of the whole mapping: the slab replaces the div. |
| Page load | Passage (designed) | A typed transition between two stages — cut, transit, slab swap, shared-element morph, flip, portal, door/room, environment fade. This is design taxonomy, not shipped choreography: today's shipped travel is rail movement within a stage. |
Slab — the atomic content-bearing volume of a spatial site: a (usually thin) box with an optional solid body and an optional frame around its edges. It replaces the div as the unit of content, and a slab with neither body nor frame is a legal empty seat that simply holds a place in the layout.
The slab's box model is richer than the div's because it describes a physical object. Its two solid slots are two physical facts about one thing: the body is what it is made of, and the frame is what edges it. Nothing in that anatomy requires abandoning how you think about a card in a grid; it extends the card with depth.
What a slab does not carry is markup, so the div's contents do not come along. Each thing the div held is re-authored as a scene element parented to the slab, where it rides the slab's solved pose: words become distance-field text, pictures become image planes, and controls become the Studio's glass UI controls, whose operable ones (button, switch, checkbox, radio, slider, text field) each keep a native form element placed right after the canvas. Content with no scene equivalent stays in the host page around the scene.
Translating grid definitions
Your CSS stays the layout engine — the migration does not replace grid with a custom 3D constraint solver. In the Studio, a rig builds an offscreen measurement twin: a hidden DOM tree containing one element per layout cell, laid out by your own stylesheet. The browser solves the grid there exactly as it would on a flat page; the rig reads each cell's solved rectangle and maps it into world space at a fixed projection contract of 50 CSS px = 1 world unit (an engine convention, exported as a named constant — the source comment calls it a "contract constant, not folklore"). A 200 px card is 4 world units wide, always.
The twin exists because a slab is a record in the scene document, not a DOM element, so it has no box of its own for CSS to lay out. The engine builds an empty stand-in box per slab instead, carrying the slab's declared size, and poses each slab at the center of the box the browser solved for its stand-in. The twin is a layout-measurement container only — neither the platform's Shadow DOM nor a hidden state store: state for the 3D elements lives with the elements themselves (their records, clips, and state machines, whose definitions the scene document saves), never in any hidden DOM. The practical consequence for a migrating team is that a grid definition ports almost verbatim. Here is a 2D card row:
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
And the same layout expressed as a rig definition — the grid rule is carried over intact, scoped to the rig's container selector. In the Studio a rig is created from a definition of this shape through the engine's command interface (add_rig); the workshop has no layout panel:
{
id: 'cards',
type: 'plane',
// 6 world units → a 300 px twin container
plane: { position: [0, 0, 0], widthWorld: 6 },
css: '[data-rig="cards"] { display: grid; ' +
'grid-template-columns: repeat(3, 1fr); gap: 20px; }',
slabs: [
{ id: 'a', widthPx: 60, heightPx: 60,
body: { type: 'box', material: 'glass' } },
{ id: 'b', widthPx: 60, heightPx: 60 }, // no body: an empty seat
{ id: 'c', widthPx: 60, heightPx: 60 },
],
}
Three honesty notes on that example, because they are the places migrations stumble:
- Slabs are empty by default. A slab that declares only its id and size carries no content; content is an explicit
bodyorframe, or a shape parented to the slab. (This default was ruled on .) Until it holds something, an empty slab shows its bounds as a faint pale-blue glass box, so the seat can be seen; the box clears when a shape is parented to the slab, andmarker: falseturns it off. - Slab bodies are
boxandboxframeonly. A widening experiment that allowed any primitive as a body was reverted after adversarial review surfaced 17 defects (). For other shapes, the sanctioned pattern is an empty seat slab with primitives parented to it, inheriting its rig-solved pose. - Scope every rule with
[data-rig="..."]. The rig's stylesheet is injected globally, so an unscoped.cardrule reaches every matching element in the host page, not just this rig's cells.
Sizing is forgiving in the direction that matters: when the plane bounds a dimension, the runtime auto-fits each slab's declared pixel size down to the room the grid granted it — never up — so a modest declared size fits any reasonable container. Spans work the ordinary CSS way (grid-column: span 2 on a named cell), and the solver reads the true row/column/span structure back from the browser's own solved track edges.
Translating event handling
Event handlers are the part of a migration that changes shape: a slab is a record in the scene document, not a DOM element, so it has no element of its own for a DOM listener to attach to. Instead the pointer's pixel is turned into a world ray through the render projection, the ray is tested on the CPU against the scene's pickable records, and the hit is published as a message on the scene's message bus, keyed by the record it hit. That bus is DOM-free, rebuilt over the 3D record tree: the hit's message goes to the subscribers of the channel it is published on, and a state machine bound to its own element hears a press on that element or on any of its parts. For messages sent to a record, the bus separately offers bubbling delivery, which walks record parent pointers with stopPropagation semantics instead of using the platform's event system. Keyboard support today is navigation-level — rail and detent keys — with one exception: each operable glass UI control (button, switch, checkbox, radio, slider, text field) keeps its state in a native, focusable form element placed right after the canvas, so Tab reaches those controls in authored order, and a change listener on that element fires as it would on any page. The projection mechanics of how a pointer event finds its target are covered in Event Propagation in 3D: From Screen Space to Scene Records.
What ships
The end of a migration is a static folder, not a server dependency. An exported scene from the Studio is a self-contained artifact: one single-file player script, a scene document, the scene's assets (models, font atlases, pictures), and its LICENSE.txt and NOTICES.txt, with every reference pointing inward. It plays from any static host with zero requests back to the vendor — no account, no tools, no calls home. The folder also carries a ready-to-serve index.html and main.js that boot it; a page of your own can host it with one custom element over the public player:
<s4s-scene src="./scene.json"></s4s-scene>
The player ships as a single file by design — the build inlines every dynamic import — because a published scene folder should copy exactly one engine file. Authoring capability is structurally absent from that file, not hidden behind a flag: the public player bundle contains no editing code at all, so what you publish is a document and its renderer, the same deployment shape as the 2D site you migrated from.
Related reading
- Mapping CSS Box Models and Grid Layouts to 3D Coordinates — the measurement-twin and projection mechanics in full depth.
- What Is a 3D Spatial Web Interface? — the architecture this page migrates toward, defined.
- The Scene Document Model: Document Structure for 3D Elements — how the scene you migrate into keeps the structure of the DOM it replaces: an element tree, bubbling events, stable addresses, and a document that saves.
- Event Propagation in 3D: From Screen Space to Scene Records — how a click on the canvas reaches the scene record under the pointer.
- Spatial Web Glossary — one-line definitions of stage, section, rig, slab, rail, detent, and the rest of the spatial vocabulary.