raw Software
front
back
left
right
top
bottom
Drag the scene to rotate. Dimensions update without rebuilding the DOM.

Introduction to CSS 3D Transforms

Robert Eisele

Adjust width, height, depth, and perspective, drag the preview to rotate it, then inspect generated HTML and CSS or a Trackball.js integration snippet. CSS 3D transforms position ordinary elements in perspective while preserving their content and interaction.

Read the story

CSS 3D transforms extend ordinary document layout with depth, perspective, and rotation while keeping every face a real DOM element. That makes them particularly useful for interface components: cards with two semantic sides, cyclic galleries whose structure is visible, product boxes with selectable faces, and spatial transitions between related views.

Why CSS Instead of a 3D Rendering Engine?

WebGL and libraries such as Three.js are the right tools for textured worlds, lighting models, large meshes, and programmable rendering. They are often unnecessary for an interface made from a handful of rectangular panels. CSS keeps those panels in normal HTML: text remains selectable, buttons retain native behavior, accessibility APIs can still inspect the content, and the browser can composite the transformed layers efficiently.

This does not turn CSS into a modeling system. It adds spatial relationships to an interface. A two-sided settings panel communicates that there are exactly two related views. A ring of slides communicates that advancing past the last item returns to the first. In both cases depth explains structure rather than merely decorating it.

Modern evergreen browsers support the unprefixed transform properties used here. Legacy prefix tables and old Internet Explorer limitations are no longer useful design targets for new interfaces. A robust implementation should instead provide a readable untransformed fallback and respect reduced-motion preferences.

The CSS 3D Coordinate System

An element begins in its own local plane. The x-axis points right, the y-axis points down, and positive z points toward the viewer. The most common transform functions are

A positive translateZ moves an element toward the viewer, but it becomes visually larger only after a perspective projection has been introduced. Without perspective, the browser uses an orthographic view: parallel lines stay parallel and distance along z does not produce foreshortening.

Perspective Is a Camera Distance

Let the viewer be a distance \(d\) in front of the projection plane. A point \((x,y,z)\) is projected by the scale factor

\[ s(z)=\frac{d}{d-z}, \qquad x_{\mathrm{screen}}=s(z)x, \qquad y_{\mathrm{screen}}=s(z)y. \]

Positive z makes \(d-z\) smaller, so the point appears larger. Increasing \(d\) pushes every scale factor closer to one and weakens the effect. This is why perspective: 300px looks dramatic while perspective: 1600px looks restrained.

300px
700px
1600px

Function versus Parent Property

Perspective can be introduced inside one element's transform list:

.panel {
  transform: perspective(600px) rotateY(45deg);
}

This is compact and useful for one isolated element. Each transformed element, however, receives its own projection and therefore its own vanishing point. Several neighboring panels will not look as if they occupy one room.

For a shared 3D scene, put the property on their common parent:

.scene {
  perspective: 600px;
}

.scene__panel {
  transform: rotateY(45deg);
}

Every transformed child is then projected through the same virtual camera. The perspective-origin property moves the vanishing point within that parent. Its default is the center; values such as perspective-origin: 20% 50% make the viewer appear to stand left of center.

Transform Order Is Part of the Geometry

Transform functions do not commute. CSS composes the listed functions as matrices, and the rightmost function acts on a point first. Therefore

transform: rotateY(45deg) translateZ(120px);

first moves the element 120 pixels along its local z-axis and then rotates that displaced plane. Reversing the list rotates the element in place and then translates it along the parent's z-axis. The visible results differ because, in matrix notation,

\[ R_y(\theta)T_z(d)\ne T_z(d)R_y(\theta). \]

Homogeneous coordinates combine rotation and translation into one \(4\times4\) matrix. For example,

\[ T_z(d)= \begin{pmatrix} 1&0&0&0\\ 0&1&0&0\\ 0&0&1&d\\ 0&0&0&1 \end{pmatrix}, \qquad R_y(\theta)= \begin{pmatrix} \cos\theta&0&\sin\theta&0\\ 0&1&0&0\\ -\sin\theta&0&\cos\theta&0\\ 0&0&0&1 \end{pmatrix}. \]

The nonzero translation column moves when the matrices are multiplied in the opposite order. That algebraic fact is the reason cube faces are normally written as a rotation followed by translateZ: each face is first moved along its own outward normal.

Keeping Descendants in 3D

A transformed parent is flattened by default. Its children may have z coordinates, but the parent composites them into one plane before that plane joins the surrounding scene. To build an object from nested faces, preserve their depth relationship:

.object {
  transform-style: preserve-3d;
}

This property is not inherited. Every intermediate wrapper that must preserve depth needs its own declaration. Certain grouping and compositing effects can still force flattening, including non-default opacity, filters, clipping, and paint containment. When a deeply nested object suddenly looks flat, inspect its ancestors rather than only the face transforms.

A Two-Sided Card

A card needs two coincident faces. The back starts rotated by \(180^\circ\), both faces hide their reverse sides, and the shared parent performs the flip. Hover the example or focus its button.

.card-scene {
  perspective: 800px;
}

.card {
  position: relative;
  transform-style: preserve-3d;
  transition: transform 600ms ease;
}

.card__face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
}

.card__back {
  transform: rotateY(180deg);
}

.card.is-flipped {
  transform: rotateY(180deg);
}

backface-visibility changes painting, not document semantics. Both faces remain in the DOM. Production components should update focusability and accessibility state when a face becomes inactive rather than merely turning it away from the viewer.

Building a Cube and a Rectangular Box

The designer above generalizes a cube into a box with width \(w\), height \(h\), and depth \(d\). Its origin is at the center. The front and back faces have size \(w\times h\) and lie at \(z=\pm d/2\). The left and right faces have size \(d\times h\) and lie at \(x=\pm w/2\). The top and bottom faces have size \(w\times d\) and lie at \(y=\pm h/2\).

A front face already points along positive z, so it only needs translateZ(d/2). A right face must first turn its normal toward positive x and then move outward:

.box__front {
  width: var(--width);
  height: var(--height);
  transform: translate(-50%, -50%) translateZ(calc(var(--depth) / 2));
}

.box__right {
  width: var(--depth);
  height: var(--height);
  transform: translate(-50%, -50%) rotateY(90deg)
             translateZ(calc(var(--width) / 2));
}

The remaining four faces follow by symmetry. Centering every rectangle with translate(-50%, -50%) lets all dimensions change around one stable origin, which is why the Stage tool can resize the box without rebuilding its DOM.

Extension 1: Rotating the Complete Cube

Once the six local face transforms have built the cube, orientation belongs on their common parent. Animating that one parent preserves every edge and avoids recalculating individual faces. The x rotation establishes a slightly elevated view while the y rotation completes one revolution from \(0\) to \(2\pi\).

Front
Back
Left
Right
Top
Bottom
.cube {
  transform-style: preserve-3d;
  animation: cube-spin 8s linear infinite;
  animation-play-state: paused;
}

.cube.is-running {
  animation-play-state: running;
}

@keyframes cube-spin {
  from { transform: rotateX(-20deg) rotateY(0deg); }
  to { transform: rotateX(-20deg) rotateY(360deg); }
}

The face transforms remain constant throughout the animation. The loop starts paused and runs only after the play control is pressed. Stopping the parent therefore freezes a valid cube at every instant rather than leaving six independently animated rectangles between states.

Extension 2: Unfolding Along the Edges

Unfolding is not another rotation of the complete cube. Each moving face must rotate around a shared edge. In CSS, that edge is a transform-origin. The left, right, top, and bottom hinges belong to the front face; the back hinge is nested inside the bottom hinge. This hierarchy keeps the back attached while two successive \(90^\circ\) folds close the cube.

Front
Left
Right
Top
Bottom
Back
.hinge {
  transform-style: preserve-3d;
  transition: transform 900ms ease-in-out;
}

.hinge--right {
  left: 100%;
  transform: rotateY(90deg);
  transform-origin: 0 50%;
}

.hinge--bottom {
  top: 100%;
  transform: rotateX(-90deg);
  transform-origin: 50% 0;
}

.cube.is-unfolded .hinge {
  transform: rotateX(0deg) rotateY(0deg);
}

In the unfolded state all hinge angles are zero, producing a planar net. In the closed state every side contributes one quarter-turn. Since the back is a child of the bottom hinge, its orientation is the product of both rotations, exactly as nested transform matrices predict.

Deriving a Circular Carousel

Place \(n\) equal panels of width \(w\) around the sides of a regular \(n\)-gon. Each panel is tangent to a circle centered on the carousel axis. The angle from one panel center to the next is

\[ \Delta\theta=\frac{2\pi}{n}. \]

Split one sector in half. The resulting right triangle has opposite side \(w/2\), adjacent side \(R\), and angle \(\pi/n\). Therefore

\[ \tan\left(\frac{\pi}{n}\right)=\frac{w/2}{R}, \qquad \boxed{R=\frac{w}{2\tan(\pi/n)}}. \]

The Plot.js sketch shows the top view: \(R\) is the apothem from the center to a panel, not the distance to a polygon corner. For eight panels of width 120px, the formula gives \(R\approx144.85\text{px}\), rounded to 145px in the live CSS example below.

const count = 8;
const panelWidth = 120;
const radius = panelWidth / (2 * Math.tan(Math.PI / count));

panels.forEach((panel, index) => {
  const angle = index * 360 / count;
  panel.style.transform =
    `rotateY(${angle}deg) translateZ(${radius}px)`;
});

Rotating the carousel parent by \(-k\Delta\theta\) brings panel \(k\) to the front. The cyclic topology is now visible in the geometry itself; no special jump from the last slide back to the first is needed. The demonstration remains still until its play control is activated.

A Ring Built from Rectangular Rods

The carousel construction also works for solid elements. Let \(m\) identical rods stand on a circle of radius \(R\). Rod \(i\) receives the angular position

\[ \theta_i=\frac{2\pi i}{m}, \qquad i=0,1,\ldots,m-1, \]

followed by translateZ(R). These placement transforms remain fixed. Each rod below is itself a rectangular prism with six faces. Its center lies on the circle, and transform-origin: 50% 50% makes it rotate around that center. Its initial rotateX(90deg) transform lays it horizontally across the circular path.

Let the center of the moving wave be \(c(t)=mt/T\pmod m\). The shortest cyclic distance from rod \(i\) to that center is

\[ d_i(t)=\min\bigl(\lvert i-c(t)\rvert,\ m-\lvert i-c(t)\rvert\bigr). \]

A Gaussian weight turns that distance into a rotation angle,

\[ g_i(t)=\exp\left(-\frac{d_i(t)^2}{2\sigma^2}\right), \qquad \alpha_i(t)=90^\circ\bigl(1-g_i(t)\bigr). \]

The rod under the peak is upright, its neighbors are partially rotated, and distant rods remain horizontal. As the peak passes, every rod rotates back around its own center. No rod travels around the circle; only the Gaussian field moves through the fixed rods.

const count = 20;
const radius = 126;

for (let index = 0; index < count; index++) {
  const angle = index * 360 / count;
  position.style.transform =
    `rotateY(${angle}deg) translateZ(${radius}px)`;
}
const sigma = 0.85;

function rodAngle(index, center) {
  const direct = Math.abs(index - center) % count;
  const distance = Math.min(direct, count - direct);
  const weight = Math.exp(-(distance * distance) / (2 * sigma * sigma));
  return 90 * (1 - weight);
}
}

Increasing \(m\) closes the visible gaps and makes the arrangement approach a continuous cylindrical surface. Decreasing it exposes the individual local coordinate frames more clearly.

A 3 × 3 × 3 Compound Cube

More complex CSS objects can be assembled by translating a reusable primitive. A three-layer cube uses all index triples

\[ (i,j,k)\in\{-1,0,1\}^3. \]

With center spacing \(p\), each small cube is placed at \((ip,jp,kp)\). There are \(3\cdot3\cdot3=27\) cubes and therefore 162 face elements. A gap between neighboring cubes keeps the internal three-layer structure legible while the parent rotates.

const spacing = 50;

for (let z = -1; z <= 1; z++) {
  for (let y = -1; y <= 1; y++) {
    for (let x = -1; x <= 1; x++) {
      cube.style.transform =
        `translate3d(${x * spacing}px,
                     ${y * spacing}px,
                     ${z * spacing}px)`;
    }
  }
}

Recycling Faces for Sequential Content

Four physical faces surround a rectangular prism, while JavaScript replaces the content of the next hidden face before rotating the prism by one quarter-turn. Any number of logical items can therefore reuse the same four DOM faces without coupling content management to the geometry.

The example below supports horizontal and vertical rotation, previous and next navigation, direct jumps, configurable duration and easing, and dynamic replacement of the current item. It uses only CSS transforms, standard DOM methods, and one transitionend listener. Autoplay is deliberately omitted so content does not move while it is being read.

If \(r\) is the accumulated number of quarter-turns, the visible physical side is selected by

\[ s(r)=((r\bmod 4)+4)\bmod 4. \]

The double modulo keeps the result in \(\{0,1,2,3\}\) for negative rotations. Before changing \(r\), the target logical item is written into physical side \(s(r\pm1)\). The browser then interpolates only the parent matrix.

const horizontalSides = ["front", "right", "back", "left"];

function show(index, direction) {
  rotation += direction;
  const side = horizontalSides[mod(rotation, 4)];

  renderContent(side, items[index]);
  box.style.transform = `rotateY(${-90 * rotation}deg)`;
}

Content management and geometry remain separate: adding or replacing an item changes the data array, while axis, duration, and easing remain ordinary classes and CSS custom state. There is no selector wrapper, plugin registry, detached-content cache, or library-specific event namespace to maintain.

A Horizontally Rotating Text Tube

Text can be wrapped around a horizontal axis by placing \(n\) duplicate lines tangent to a circle. Line \(i\) uses

\[ M_i=R_x\left(\frac{2\pi i}{n}\right)T_z(r). \]

The complete tube rotates around x, while every line retains its local tangent plane. The apparent color progression below is made from a fixed sequence of solid letter colors rather than a CSS gradient.

for (let index = 0; index < count; index++) {
  const angle = index * 360 / count;
  line.style.transform =
    `rotateX(${angle}deg) translateZ(${radius}px)`;
}

Text on a Double Helix

A helix combines circular motion with constant axial progress. For character \(i\), angular step \(\Delta\theta\), radius \(r\), and vertical spacing \(h\), use

\[ p_i=\left(r\cos(i\Delta\theta),\ hi,\ r\sin(i\Delta\theta)\right). \]

The second strand adds \(\pi\) to the angle, placing it on the opposite side. Rotating only the common parent reveals the depth ordering without changing either strand's coordinates.

Two Carousel Text Rings

Two rings can share the same center and radius while remaining independent rendering objects. The lower red ring is fixed at a reference angle. Only the upper white ring receives an animation, making the relative rotation directly visible.

const count = 8;
const radius = 142;

items.forEach((item, index) => {
  item.style.transform =
    `rotateY(${index * 360 / count}deg) translateZ(${radius}px)`;
});

A Segmented Typographic Cigarette

This construction uses the same cylindrical placement principle as the text tube, but the surface is divided along its axis into a long paper body, a wider ochre filter, and a short red tip. Sixteen narrow facets and a smaller radius produce a slimmer silhouette, while dark blue-gray lettering remains readable on the light paper.

Four Cubes for the Current Year

A row of four digit prisms reads the current year from the browser clock. Each prism carries four digits around its x-axis. One button applies a full \(360^\circ\) turn with successive delays of 120ms, so the year rolls across the row instead of moving as one rigid object.

This is a finite transition rather than an endless animation, so it uses a direct action button instead of the Play/Stop control used by looping demonstrations.

Stacked Type Above a Reflective Floor

Repeating the same glyph outline at successive z positions creates a typographic extrusion without converting the letters to meshes. Alternating solid colors make the individual layers readable, while a second, vertically inverted stack suggests a reflection below a thin floor plane. The reflection is deliberately subdued so it remains secondary to the real text.

for (let layer = depth; layer >= 0; layer--) {
  copy.style.transform = `translateZ(${-layer * step}px)`;
  stack.append(copy);
}

A Team Page as a Rotating Spatial Grid

A team overview does not need a canvas renderer. The green buttons below occupy distinct cells drawn randomly from a finite grid when the page loads. Their z coordinates vary as well, so scale and overlap communicate depth. Clicking any arrow rotates the complete field through one turn around its horizontal axis and reveals the corresponding team profile.

\[ y'=y\cos\alpha-z\sin\alpha, \qquad z'=y\sin\alpha+z\cos\alpha. \]

Grid cells are shuffled once and then retained during resizing. This preserves the apparently random composition without allowing two controls to collide. The implementation uses semantic buttons, CSS transforms, and standard DOM methods; no canvas renderer is required.

Interactive Rotation with a Virtual Trackball

Euler-angle sliders are useful for inspecting one axis, but free dragging should not accumulate rotations as three independent angles. Their order matters, and some orientations make two axes coincide. A unit quaternion stores one orientation without that singularity. During a drag, a virtual trackball maps the previous and current pointer positions to vectors on a sphere and computes the rotation between them.

The Stage designer uses Trackball.js, which builds on Quaternion.js and emits a CSS rotate3d transform:

const trackball = new Trackball({
  scene: viewport,
  q: Quaternion.fromEuler(-0.38, 0.62, 0.08, "XYZ"),
  inertia: { damping: 0.92 },
  onDraw(quaternion) {
    box.style.transform = quaternion.toCSSTransform();
  }
});

The quaternion changes only the box parent's orientation. Face transforms continue to describe the box in local coordinates. Separating object construction from camera interaction keeps the model understandable and makes the generated CSS reusable without the trackball.

Depth Ordering, Compositing, and Hit Testing

CSS transforms affect painting but do not turn the DOM into a general-purpose scene graph. The browser sorts transformed descendants within their 3D rendering context, yet stacking contexts, clipping, and flattened ancestors still delimit that context. Intersecting planes can expose implementation-dependent ordering because CSS has no mesh-level depth-buffer contract comparable to WebGL.

Pointer hit testing follows the transformed painted geometry. Decorative faces can use pointer-events: none so the scene receives drag input consistently. Interactive content should not use that shortcut: each visible control needs a reachable DOM target and a predictable focus order.

Performance and Responsible Motion

CSS 3D is most successful when the flat interface remains understandable. Perspective, depth, and motion should clarify how parts relate, while the underlying HTML continues to carry content, controls, and reading order.