3DFree

Spatial UI Panels

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.

CSS 3DPerspectiveUI

Updated

Hover or click the scene to interact

This effect is free — the 3D category contains 50 effects total, including 8 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

Virtual reality room · NantesChoose your mission

The menu mirrors the headset's: on site, you will pick the same way.

① Picking an experience at a virtual reality venue — booking page

WhenThree panels "🏰 The Keep", "🚀 Orion Station", "🌊 Abyss" on a night background; the hovered panel comes to face you with its number of players and length. The page runs in the browser: it imitates the headset menu, it does not use it.
WhyThe arc of panels echoes the way menus are set around the player in a headset, which prepares customers for what they will see on site.
SettingsRoot with no fixed height or background, filling the scene between the title and the footer; panels at 27% of the width (150 px at most), stronger indigo glass, 30 px icons; "2–6 players · 30 min" line lit in the panel in front.

Bright 2-bedroom, 62 m²€198,000

Angers, Saint-Serge · 3rd floor with elevator

Viewings Saturday morning · Agence du Port

② The three rooms of an apartment for sale — a real estate agency's listing

WhenListing "Bright 2-bedroom, 62 m²": panels "🛋️ Living room 28 m²", "🍳 Kitchen 9 m²", "🛏️ Bedroom 12 m²"; hovering a room brings it to face you with a thumbnail of the room.
WhyThe arc gives the feeling of standing in the middle of the home, the rooms around you; one panel at a time comes to the front.
SettingsCream background, white glass at 55% with a brown border, dark text; thumbnail drawn with gradients, lit in the panel in front; under 480 px wide, panels at 22% and smaller text so the panel in front stays inside the scene.

Greenhouse 2 · tomatoesSettings

Market gardener at Les Jardins de la Vallée · last reading 2 min ago

③ Three settings of a connected greenhouse — mock-up of a market gardener's app

WhenPanels "🌡️ Temperature 22 °C", "💧 Watering 06:00", "☀️ Shading 40%"; the hovered one comes forward with its slider.
WhyThree settings, no more: the arc puts each control at the same distance from the eye, and the active panel stands out without opening a screen.
SettingsDark green background, emerald glass rgba(16, 185, 129, .14); panels widened to 29%; slider drawn under the value, lit in the panel in front.

How it works

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.

Accessibility

  • Reduced motion: under 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.
  • Keyboard: three real buttons, reached with Tab; focus gives the hover state plus a 2 px #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.
  • Contrast: #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.
  • Touch: with a finger, the tapped panel faces you and the arc turns toward it (measured with Chromium touch emulation at 375 px and in Firefox); the same tap also fires the button's click (measured with Chromium emulation, not checked on iPhone).

Browser compatibility

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.

Chrome 86+✓ Full
Firefox 103+✓ Full (glass blur since 103)
Safari✓ Not checked on a real Safari; unprefixed glass blur since Safari 18
Edge 86+✓ Full
Mobile iOS✓ Not checked on a real iPhone
Android Chrome✓ Full (touch measured with Chromium emulation)

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).

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="fx-0041-root">
  <div class="xr-spatial-panels">
    <button type="button" class="xr-panel"><span class="xr-panel-icon" aria-hidden="true">&#128202;</span>Stats</button>
    <button type="button" class="xr-panel"><span class="xr-panel-icon" aria-hidden="true">&#9881;</span>Settings</button>
    <button type="button" class="xr-panel"><span class="xr-panel-icon" aria-hidden="true">&#128100;</span>Profile</button>
  </div>
</div>
CSS
.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;
  }
}
JavaScript (fx-0041)
(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 = '';
    });
  });
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
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.

FAQ

Is it virtual reality (WebXR)?

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.

How does the parallax work?

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.

Can I use it on a phone?

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.