Virtual reality room · NantesChoose your mission
The menu mirrors the headset's: on site, you will pick the same way.
Three glass panels (icon and label) set in an arc in space — sides turned ±25°, center forward —; the panel that is hovered, tapped or reached with the keyboard turns to face you and moves forward (70 px, ×1.1), and the whole arc turns slightly under the pointer, which shifts the panels according to their depth: a "spatial" menu in 3D CSS and 1.2 KB of JavaScript, in an ordinary web page, with no headset and no WebXR.
Updated
This effect is free — the 3D category contains 50 effects total, including 8 free. Effect.Labs has 840 vanilla effects. Explore the category →
Virtual reality room · NantesChoose your mission
The menu mirrors the headset's: on site, you will pick the same way.
Bright 2-bedroom, 62 m²€198,000
Angers, Saint-Serge · 3rd floor with elevator
Viewings Saturday morning · Agence du Port
Greenhouse 2 · tomatoesSettings
Market gardener at Les Jardins de la Vallée · last reading 2 min ago
The root .fx-0041-root (full width, 250 px tall, rgb(26, 26, 36) background, overflow: hidden) carries perspective: 800px and the --text-secondary variable. Inside, .xr-spatial-panels fills the space, centers its children with a 20 px gap and is set to transform-style: preserve-3d: it is the group that turns. The three panels are <button class="xr-panel"> elements of 80 × 100 px: indigo glass (rgba(99, 102, 241, .15), 30% border, backdrop-filter: blur(10px)), 12 px corners, an emoji icon hidden from screen readers and a label.
The arc is written panel by panel: the first takes rotateY(-25deg) translateZ(30px), the second translateZ(50px), the third rotateY(25deg) translateZ(30px). On hover (or :focus-visible), the panel turns to face you, moves forward to 70 px and grows by 10%, in 0.5 s with a fast start (cubic-bezier(.23, 1, .32, 1)). The script looks for every .fx-0041-root on the page and follows the pointer: it computes its position in the root (from −0.5 to +0.5) and turns the group by up to 6° with rotateX and 8° with rotateY — the pointer's side moves back —, in 0.4 s; when the pointer leaves, the arc faces you again.
That turn is what gives the parallax: since the panels sit at 30, 50 or 70 px of depth in the same group, they don't move by the same amount. Measured in Chromium 151 and Firefox 153, pointer from the left edge to the right edge: the center panel slides 13 px, the side ones 2.6 px. For the group to turn as one block, the perspective is on the root and preserve-3d on the group. With a finger, pointerdown turns the arc toward the touched point, and it stays there until the next touch. Two copies on one page each have their own instance: measured, only the hovered copy turns. This is neither virtual reality nor WebXR: a web page whose staging echoes the arc menus of headsets.
prefers-reduced-motion: reduce, the script no longer turns the arc, and a block scoped to .fx-0041-root keeps each panel in place: on hover or focus, it lights up (indigo background at 35%, rgba(165, 180, 252, .8) border, white text), with no position transition. Measured in Chromium and Firefox: arc and panel still, 4.9% of pixels change on hover.#a5b4fc outline. The icons are marked aria-hidden="true": the screen reader reads "Stats", "Settings", "Profile". In Safari, Tab only stops on buttons when the "Press Tab to highlight each item" option is on (otherwise Option + Tab) — a browser setting, measured in WebKit.#a1a1aa label at 5.7:1 on the glass over the original background; white text at 11.2:1 on the lit panel of reduced motion. The label is 0.65 rem (10.4 px): for a real menu, raise it to at least 0.8 rem.3D CSS (perspective on the root, preserve-3d on the group, three transformed buttons), backdrop-filter, :focus-visible + 1.2 KB of JavaScript (Pointer Events, matchMedia), no dependencies. Measured in Chromium 151 and Firefox 153: same arc, same parallax (13 px at the center), same keyboard, same reduced motion, two independent copies, 0 errors; top demo and scenes checked at 1,280 and 375 px wide. Playwright's WebKit flattens all preserve-3d 3D: Safari rendering not checked on a real Safari.
Without JavaScript, the arc no longer turns, but the panels stay in an arc and hover still brings them to face you: only the parallax goes away. Without backdrop-filter, the glass stays a flat translucent indigo. For Safari before version 18, add -webkit-backdrop-filter: blur(10px).
Copy the three blocks into your page. No dependencies.
<div class="fx-0041-root">
<div class="xr-spatial-panels">
<button type="button" class="xr-panel"><span class="xr-panel-icon" aria-hidden="true">📊</span>Stats</button>
<button type="button" class="xr-panel"><span class="xr-panel-icon" aria-hidden="true">⚙</span>Settings</button>
<button type="button" class="xr-panel"><span class="xr-panel-icon" aria-hidden="true">👤</span>Profile</button>
</div>
</div>
.fx-0041-root,
.fx-0041-root *,
.fx-0041-root *::before,
.fx-0041-root *::after {
box-sizing: border-box
}
.fx-0041-root {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: rgb(255, 255, 255);
font-family: sans-serif;
perspective: 800px;
}
/* Site design system variables this code depends on (values from css/style.css — adapt freely). */
.fx-0041-root {
--text-secondary: #a1a1aa;
}
/* The whole arc tilts a little toward the pointer (JS): panels at different depths shift apart = parallax. */
.xr-spatial-panels {
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.4s ease-out;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.xr-panel {
width: 80px;
height: 100px;
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 12px;
backdrop-filter: blur(10px);
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 0.65rem;
color: var(--text-secondary);
text-align: center;
font-family: inherit;
cursor: pointer;
transition: transform 0.5s cubic-bezier(0.23, 1, 0.32, 1);
}
.xr-panel:nth-child(1) {
transform: rotateY(-25deg) translateZ(30px);
}
.xr-panel:nth-child(2) {
transform: rotateY(0deg) translateZ(50px);
}
.xr-panel:nth-child(3) {
transform: rotateY(25deg) translateZ(30px);
}
.xr-panel:hover,
.xr-panel:focus-visible {
transform: rotateY(0deg) translateZ(70px) scale(1.1);
}
.xr-panel-icon {
font-size: 1.5rem;
margin-bottom: 6px;
}
.xr-panel:focus-visible {
outline: 2px solid #a5b4fc;
outline-offset: 3px;
}
/* Reduced motion: no tilt, and the chosen panel stays in place; it lights up instead. */
@media (prefers-reduced-motion: reduce) {
.fx-0041-root .xr-spatial-panels { transform: none; transition: none; }
.fx-0041-root .xr-panel { transition: background-color 0.2s, border-color 0.2s, color 0.2s; }
.fx-0041-root .xr-panel:nth-child(1):hover,
.fx-0041-root .xr-panel:nth-child(1):focus-visible { transform: rotateY(-25deg) translateZ(30px); }
.fx-0041-root .xr-panel:nth-child(2):hover,
.fx-0041-root .xr-panel:nth-child(2):focus-visible { transform: rotateY(0deg) translateZ(50px); }
.fx-0041-root .xr-panel:nth-child(3):hover,
.fx-0041-root .xr-panel:nth-child(3):focus-visible { transform: rotateY(25deg) translateZ(30px); }
.fx-0041-root .xr-panel:hover,
.fx-0041-root .xr-panel:focus-visible {
background: rgba(99, 102, 241, 0.35);
border-color: rgba(165, 180, 252, 0.8);
color: #fff;
}
}
(function() {
// ============================================
// SPATIAL UI PANELS - parallax: the arc turns slightly under the pointer
// ============================================
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('.fx-0041-root').forEach(function(root) {
var arc = root.querySelector('.xr-spatial-panels');
if (!arc) return;
// The pointer's side moves back (up to 8 deg left and right, 6 deg top and bottom); forward panels (50 to 70 px) move more than the ones behind.
function tilt(e) {
if (reduce.matches) return;
var r = root.getBoundingClientRect();
var x = (e.clientX - r.left) / r.width - 0.5;
var y = (e.clientY - r.top) / r.height - 0.5;
arc.style.transform = 'rotateX(' + (-y * 12).toFixed(2) + 'deg) rotateY(' + (x * 16).toFixed(2) + 'deg)';
}
root.addEventListener('pointermove', tilt);
root.addEventListener('pointerdown', tilt); // with a finger: the arc turns toward the touched point and stays there
root.addEventListener('pointerleave', function(e) {
if (e.pointerType !== 'touch') arc.style.transform = '';
});
});
})();
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Arc opening (CSS — rotateY of panels 1 and 3) |
−25° and 25° | ±35° closes the arc around the viewer; ±10° makes it almost flat. A 4th or 5th panel needs its own :nth-child line, with a wider angle toward the edges. |
Depths (CSS — translateZ at rest and on hover) |
30 / 50 px at rest; 70 px on hover | The bigger the gaps, the more the parallax shows: with 20 / 60 / 100 px, the center panel slides further than the sides. |
Parallax strength (JS — * 12 and * 16) |
6° and 8° at the root's edge | Double both for a strong effect; set 0 for a fixed arc that only keeps the hover. |
Chosen panel zoom (CSS — scale(1.1)) |
×1.1 | ×1.2 clearly brings the panel forward, but the root (overflow: hidden) may then clip it on a small screen. |
Glass (CSS — background, border, backdrop-filter of .xr-panel) |
indigo at 15%, 30% border, 10 px blur | The blur only shows when something passes behind: set the root over an image or a gradient. On a light background, switch to white glass at 50-60% and dark text. |
Size (CSS — root height, panel width and height) |
250 px; 80 × 100 px | The scenes give the panels a width in percent (27%, 22% on phones) so they fit at every size. |
No. It is an ordinary web page: three buttons transformed with 3D CSS and a small script that turns the arc under the pointer. No headset, no WebXR API, nothing to allow. The "Spatial UI" name comes from the staging, which echoes the arc menus of headsets: handy to present a virtual reality experience or give a "spatial" feel to a choice of three options.
The script moves no panel: it turns their group by a few degrees, the side where the pointer is moving back. Since the panels sit at different depths in that preserve-3d group, the turn shifts them by different amounts: measured, 13 px for the center panel versus 2.6 px for the sides, from one edge to the other. It is the same principle as a landscape scrolling faster in the foreground.
Yes: with a finger, the tapped panel faces you and the arc turns toward it, and the tap triggers the button. Keep panel widths in percent of the width (about 22% under 480 px, as in the scenes): the panel in front grows, and the root, set to overflow: hidden, clips whatever spills over.