Blog Recipes Links

Introduction to 3D in CSS

H E L P !

This article is a modest introduction to 3D in CSS through an example. I share with you how I made a Minecraft creeper head in CSS and animated it with JavaScript. This article is composed of 2 parts:

  1. Modeling a Minecraft creeper head.

  2. Making the creeper alive.

Modeling the cube

Modeling the creeper head is like modeling a cube. I use 2 assets:

Each side of the cube is represented by an img tag and is nested in a div element. We need 6 images for the 6 square faces:

HTML
<div class="creeper">
  <img src="face.avif" alt="" class="side front" />
  <img src="side.avif" alt="" class="side back" />
  <img src="side.avif" alt="" class="side top" />
  <img src="side.avif" alt="" class="side bottom" />
  <img src="side.avif" alt="" class="side left" />
  <img src="side.avif" alt="" class="side right" />
</div>

By default, HTML elements are positioned in a two-dimensional space, meaning that everything is flat.

Is it a menacing flat mouth or a hairy moustache?

transform-style is a CSS property indicating the way children elements are positioned in the space. With the keyword preserve-3d, we unlock the Z dimension and add some depth to the page. We can now position elements relative to the X, Y and Z axis. The CSS function translateZ position an element closer or farther away from the viewer:

CSS
.creeper {
  --size: 128px;
  --half-size: calc(var(--size) / 2);

  margin: 0 auto;
  width: var(--size);
  height: var(--size);
  /* 👇 Elements are now positioned in a 3D space */
  transform-style: preserve-3d;
}

.side {
  position: absolute;
  width: var(--size);
  height: var(--size);
}

.front {
  transform: translateZ(var(--half-size));
}
.back {
  transform: translateZ(calc(var(--half-size) * -1));
}
.top {
  transform: translateY(calc(var(--half-size) * -1)) rotateX(90deg);
}
.left {
  transform: translateX(calc(var(--half-size) * -1)) rotateY(90deg);
}
.bottom {
  transform: translateY(var(--half-size)) rotateX(90deg);
}
.right {
  transform: translateX(var(--half-size)) rotateY(90deg);
}

The following figure can help you understand how the elements are positioned in the 3D space:

Hover me

Bringing the cube to life

This part is optional. The modeling is over, good job Jackson. The character looks very static though: I would like him to follow the cursor for more user interaction. To do so, we need to compute 2 angles. Once calculated, we can use rotateX and rotateY to position the cube relative to the axis of rotation X and Y. It has to be done in JavaScript:

<div class="creeper" id="demo">
  <img src="face.avif" alt="" class="side front" />
  <img src="side.avif" alt="" class="side back" />
  <img src="side.avif" alt="" class="side top" />
  <img src="side.avif" alt="" class="side bottom" />
  <img src="side.avif" alt="" class="side right" />
  <img src="side.avif" alt="" class="side left" />
</div>
.creeper {
  --size: 128px;
  --half-size: calc(var(--size) / 2);

  margin: 0 auto;
  width: var(--size);
  height: var(--size);
  /* 👇 Elements are now positioned in a 3D space */
  transform-style: preserve-3d;
  /* Make the animation smoother */
  transition: 0.05s transform ease-out;
  transform: rotateX(var(--angle-x, 0deg)) rotateY(var(--angle-y, 0deg));
}

.side {
  position: absolute;
  width: var(--size);
  height: var(--size);
}

.front {
  transform: translateZ(var(--half-size));
}

.back {
  transform: translateZ(calc(var(--half-size) * -1));
}

.top {
  transform: translateY(calc(var(--half-size) * -1)) rotateX(90deg);
}

.left {
  transform: translateX(calc(var(--half-size) * -1)) rotateY(90deg);
}

.bottom {
  transform: translateY(var(--half-size)) rotateX(90deg);
}

.right {
  transform: translateX(var(--half-size)) rotateY(90deg);
}
type Options = {
  maxAngleX: number
  maxAngleY: number
}

const map = (value: number, low1: number, high1: number, low2: number, high2: number) => low2 + ((high2 - low2) * (value - low1)) / (high1 - low1)

function rotate(element: HTMLElement, angleX: number, angleY: number): void {
  element.style.setProperty('--angle-x', `${angleX}deg`)
  element.style.setProperty('--angle-y', `${angleY}deg`)
}

function handleOrientation(element: HTMLElement, options: Options): void {
  window.addEventListener('deviceorientation', (event: DeviceOrientationEvent) => {
    const decY = map((event.gamma || 0) + 180, 0, 360, -options.maxAngleY, options.maxAngleY)
    rotate(element, 0, -decY)
  })
}

function handleCursor(element: HTMLElement, options: Options): void {
  window.addEventListener('mousemove', (event: MouseEvent) => {
    const elementRect = element.getBoundingClientRect()
    const originX = elementRect.left + elementRect.width / 2
    const originY = elementRect.top + elementRect.height / 2
    const width = window.innerWidth
    const height = window.innerHeight
    const angleX = map(event.clientY - originY, -height / 2, height / 2, -options.maxAngleY, options.maxAngleY)
    const angleY = map(event.clientX - originX, -width / 2, width / 2, -options.maxAngleX, options.maxAngleX)
    rotate(element, -angleX, angleY)
  })
}

const creeper = document.querySelector('#demo') as HTMLElement
const options: Options = { maxAngleX: 40, maxAngleY: 20 }
// For touch screens
if (window.DeviceOrientationEvent && 'ontouchstart' in window) {
  handleOrientation(creeper, Object.assign({}, options, { maxAngleY: 80 }))
} else {
  handleCursor(creeper, options)
}