<figure class="scene" data-scene>
  <div class="stage" data-stage></div>
</figure>

<aside class="controls">
  <div>
    <label>x rotation</label>
    <input class="range" type="range" min="-180" max="180" value="50" data-property="x" data-unit="deg">
    <span data-value="x"></span>
  </div>

  <div>
    <label>y rotation</label>
    <input class="range" type="range" min="-180" max="180" value="20" data-property="y" data-unit="deg">
    <span data-value="y"></span>
  </div>
  
  <div>
    <label>z rotation</label>
    <input class="range" type="range" min="-180" max="180" value="-30" data-property="z" data-unit="deg">
    <span data-value="z"></span>
  </div>
  
  <div>
    <label>perspective</label>
    <input class="range" type="range" min="10" max="1000" value="500" data-property="perspective" data-unit="px">
    <span data-value="perspective"></span>
  </div>
  
  <div>
    <label>x perspective origin</label>
    <input class="range" type="range" min="0" max="100" value="50" data-property="x-perspective-origin" data-unit="%">
    <span data-value="x-perspective-origin"></span>
  </div>

  <div>
    <label>y perspective origin</label>
    <input class="range" type="range" min="0" max="100" value="50" data-property="y-perspective-origin" data-unit="%">
    <span data-value="y-perspective-origin"></span>
  </div>
  
   <div>
    <label>font size</label>
    <input class="range" type="range" min="5" max="100" value="10" data-property="font-size" data-unit="vmin">
    <span data-value="font-size"></span>
  </div>
</aside>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:[email protected]&display=swap');

$color-primary: blue;
$color-secondary: #ffe400;

*,
*:before,
*:after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, 
body {
  width: 100%;
  height: 100%;
}

body {
  font-family: 'JetBrains Mono', sans-serif;
  color: $color-secondary;
  
  background-color: $color-primary;
}

.scene {
  --x: 50deg;
  --y: 20deg;
  --z: -30deg;
  --perspective: 500px;
  --x-perspective-origin: 50%;
  --y-perspective-origin: 50%;
  --font-size: 10vmin;
  
  position: relative;
  width: 100%;
  height: 100%;
 
  perspective: var(--perspective);
  transform-style: preserve-3d;
  perspective-origin: var(--x-perspective-origin) var(--y-perspective-origin);
}

.stage {
  position: absolute;
  top: 50%;
  left: 50%;
  transform-style: preserve-3d;
  transform: translateX(-50%) translateY(-50%) rotateX(var(--x)) rotateY(var(--y)) rotateZ(var(--z));
}

.row {
  display: flex;
  transform-style: preserve-3d;
}

.glyph {
  display: block;
  font-size: var(--font-size);
  line-height: 1em;
  will-change: transform;
}

.controls {
  position: fixed;
  bottom: 0;
  left: 0;
  z-index: 10;
  padding: 10px;
}
View Compiled
const scene = document.querySelector('[data-scene]')
const stage = document.querySelector('[data-stage]')
const glyphs = ('VOORHOEDE').split('')
const controls = document.querySelectorAll('[data-property]')

function initStage() {
  const row = `
    <div class="row" data-row>
      ${glyphs.map(glyph => `<span class="glyph" data-glyph>${glyph}</span>`).join('')}
    </div>`
  
  const rows = new Array(3).fill(row).join('')
  stage.innerHTML = rows
}

function initAnimation() {
  const rows = document.querySelectorAll('[data-row]')
  rows.forEach((row, index) => animateRow(row))
}

function animateRow(row) {
  const duration = 500
  const glyphs = row.querySelectorAll('[data-glyph]')
  const zOffset = 10
  
  glyphs.forEach((glyph, index) => {
    glyph.animate({
      transform: [`translateZ(${-zOffset}vmin)`, `translateZ(${zOffset}vmin)`],
      easing: 'ease-in-out'
    },
    {
      duration,
      delay: index * -duration * .15,
      iterations: Infinity,
      direction: 'alternate-reverse'
    })
  })
}

function initControls(controls) {
  controls.forEach(control => {
    control.addEventListener('input', controlHandler)
  })
  
  function controlHandler({ currentTarget }) {
    const { property, unit } = currentTarget.dataset
    const value = document.querySelector(`[data-value="${property}"]`)
    scene.style.setProperty(`--${property}`, `${currentTarget.value}${unit}`)
    value.innerHTML = currentTarget.value
  }
}

initStage()
initAnimation()
initControls(controls)
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.