Pure CSS Scenes & objects #loop #sass-loop #scene #wheel

Ferris wheel in pure CSS

The wheel turns on Z while every cabin turns the other way by the same amount, so the cabins stay upright. Two rims at different depths give it thickness.

  • 157 lines of CSS
  • 21 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. Every cabin is a zero-size point at the wheel’s centre with rotate(a) translateY(-68px) rotate(-a): turn, walk out to the rim, turn back. It lands on the rim, upright.
  2. The wheel spins 0 → 360deg. Each cabin appends rotate(0 → -360deg) with the same duration, so the two cancel at every moment and the cabins keep hanging straight down.
  3. Because the cabin element is a point, transform-origin is the pivot. The body is a pseudo-element hanging below it, free to swing a few degrees on its own.
  4. Depth comes cheap: two rims at translateZ(±7px), every spoke drawn twice with ::before/::after, and two A-frames in front of and behind the wheel. Turn the whole thing with rotateY to see it.

Key techniques

  • rotate → translate → rotate back
  • counter-rotating keyframes
  • transform-origin at the pivot
  • Sass @for for the angles

Copy-paste code

HTML 21 lines

<div class="scene">
  <div class="ferris">
    <div class="base"></div>
    <div class="stand"><s></s><s></s></div>
    <div class="wheel">
      <i style="--a:0deg"></i>
      <i style="--a:45deg"></i>
      <i style="--a:90deg"></i>
      <i style="--a:135deg"></i>
      <b style="--i:0;--c:#2ee6d6"></b>
      <b style="--i:1;--c:#ff4d9d"></b>
      <b style="--i:2;--c:#ffb547"></b>
      <b style="--i:3;--c:#8b6cff"></b>
      <b style="--i:4;--c:#2ee6d6"></b>
      <b style="--i:5;--c:#ff4d9d"></b>
      <b style="--i:6;--c:#ffb547"></b>
      <b style="--i:7;--c:#8b6cff"></b>
      <u></u>
    </div>
  </div>
</div>

CSS 157 lines

.scene {
  perspective: 800px;
}

.ferris {
  position: relative;
  width: 200px;
  height: 188px;
  transform-style: preserve-3d;
  transform: rotateX(-8deg) rotateY(-28deg);
}

/* a glowing pad, laid flat on the ground */
.base {
  position: absolute;
  left: 20px;
  top: 150px;
  width: 160px;
  height: 56px;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(139 108 255 / 0.4), transparent);
  transform: rotateX(90deg);
}

.stand {
  position: absolute;
  left: 38px;
  top: 74px;
  width: 124px;
  height: 104px;
  transform-style: preserve-3d;
}

/* an A-frame: a thin inverted V in one polygon */
.stand s {
  position: absolute;
  inset: 0;
  background: linear-gradient(#a18cff, rgb(139 108 255 / 0.4));
  clip-path: polygon(0 100%, 48% 0, 52% 0, 100% 100%, 95.5% 100%, 50% 5%, 4.5% 100%);
  transform: translateZ(-14px);
}

.stand s + s {
  transform: translateZ(14px);
}

.wheel {
  position: absolute;
  left: 32px;
  top: 10px;
  width: 136px;
  height: 136px;
  transform-style: preserve-3d;
  animation: spin 24s linear infinite;
}

/* two rims at different depths */
.wheel::before,
.wheel::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 3px solid #8b6cff;
  border-radius: 50%;
  box-shadow: 0 0 10px rgb(139 108 255 / 0.6);
  transform: translateZ(7px);
}

.wheel::after {
  border: 3px dotted #ffb547;
  box-shadow: none;
  transform: translateZ(-7px);
}

/* each spoke is a diameter, drawn once per rim */
.wheel i {
  position: absolute;
  top: 0;
  left: calc(50% - 1px);
  width: 2px;
  height: 100%;
  transform-style: preserve-3d;
  transform: rotate(var(--a));
}

.wheel i::before,
.wheel i::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgb(139 108 255 / 0.6);
  transform: translateZ(7px);
}

.wheel i::after {
  transform: translateZ(-7px);
}

/* a cabin: a point at the centre, pushed out to the rim */
.wheel b {
  --a: calc(var(--i) * 45deg);
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;
  animation: cabin 24s linear infinite;
}

/* the body hangs below the point and swings a little */
.wheel b::before {
  content: '';
  position: absolute;
  top: 3px;
  left: -9px;
  width: 18px;
  height: 15px;
  border-radius: 3px 3px 7px 7px;
  background: linear-gradient(var(--c) 0 30%, color-mix(in srgb, var(--c) 30%, #fff) 30% 62%, var(--c) 62%);
  box-shadow: 0 0 8px var(--c);
  transform-origin: 50% -3px;
  animation: swing 1.6s ease-in-out infinite alternate;
  animation-delay: calc(var(--i) * -0.4s);
}

.wheel b::after {
  content: '';
  position: absolute;
  top: -1px;
  left: -1px;
  width: 2px;
  height: 5px;
  background: #ccc;
}

.wheel u {
  position: absolute;
  inset: calc(50% - 8px);
  border-radius: 50%;
  background: radial-gradient(circle at 35% 35%, #fff, #ffb547 55%, #ff4d9d);
  transform: translateZ(8px);
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* the last rotate cancels the wheel's rotation */
@keyframes cabin {
  from { transform: rotate(var(--a)) translateY(-68px) rotate(calc(var(--a) * -1)) rotate(0deg); }
  to   { transform: rotate(var(--a)) translateY(-68px) rotate(calc(var(--a) * -1)) rotate(-360deg); }
}

@keyframes swing {
  from { transform: rotate(-5deg); }
  to   { transform: rotate(5deg); }
}

Sass source 199 lines

@use 'sass:list';

// Every cabin is a zero-size pivot at the wheel's centre:
//   rotate(--a) → translateY(-$r) → rotate(-(--a))   puts it on the rim, upright.
// The wheel spins 0 → 360deg; each cabin adds 0 → -360deg at the end of its transform, with the
// same duration, so at every moment the two rotations cancel and the cabin hangs straight down.
$r: 68px; // pivot radius
$depth: 7px; // half the distance between the two rims
$spin: 24s;
$colors: (var(--accent-2), var(--hot), var(--warm), var(--accent));

.d-ferris {
  position: relative;
  width: 200px;
  height: 188px;
  transform-style: preserve-3d;
  transform: rotateX(-8deg) rotateY(-28deg);

  // a glowing pad on the ground, laid flat
  &__base {
    position: absolute;
    left: 20px;
    top: 150px; // centre on the ground line (y = 178px) before it is laid flat
    width: 160px;
    height: 56px;
    border-radius: 50%;
    background: radial-gradient(closest-side, color-mix(in srgb, var(--accent) 40%, transparent), transparent);
    transform: rotateX(90deg);
  }

  // two A-frames, one behind and one in front of the wheel
  &__stand {
    position: absolute;
    left: 38px;
    top: 74px;
    width: 124px;
    height: 104px;
    transform-style: preserve-3d;

    s {
      position: absolute;
      inset: 0;
      background: linear-gradient(color-mix(in srgb, var(--accent) 70%, var(--text)), color-mix(in srgb, var(--accent) 40%, transparent));
      // an inverted V with thin legs, in one polygon
      clip-path: polygon(0 100%, 48% 0, 52% 0, 100% 100%, 95.5% 100%, 50% 5%, 4.5% 100%);
      transform: translateZ($depth * -2);
    }

    s + s {
      transform: translateZ($depth * 2);
    }
  }

  &__wheel {
    position: absolute;
    left: 100px - $r;
    top: 78px - $r;
    width: $r * 2;
    height: $r * 2;
    transform-style: preserve-3d;
    animation: d-ferris-spin $spin linear infinite;

    // the two rims, at different depths
    &::before,
    &::after {
      content: '';
      position: absolute;
      inset: 0;
      border: 3px solid var(--accent);
      border-radius: 50%;
      box-shadow:
        0 0 10px color-mix(in srgb, var(--accent) 60%, transparent),
        inset 0 0 10px color-mix(in srgb, var(--accent) 45%, transparent);
      transform: translateZ($depth);
    }

    // back rim: dimmer, with a dotted string of lights
    &::after {
      border: 3px dotted color-mix(in srgb, var(--warm) 80%, transparent);
      box-shadow: none;
      transform: translateZ(-$depth);
    }

    // Spokes: each <i> is a diameter drawn twice, once per rim.
    i {
      position: absolute;
      top: 0;
      left: calc(50% - 1px);
      width: 2px;
      height: 100%;
      transform-style: preserve-3d;

      &::before,
      &::after {
        content: '';
        position: absolute;
        inset: 0;
        background: color-mix(in srgb, var(--accent) 60%, transparent);
        transform: translateZ($depth);
      }

      &::after {
        transform: translateZ(-$depth);
      }
    }

    @for $n from 1 through 4 {
      i:nth-of-type(#{$n}) {
        transform: rotate(($n - 1) * 45deg);
      }
    }

    // Cabins: a pivot point with the body hanging from it.
    b {
      position: absolute;
      top: 50%;
      left: 50%;
      width: 0;
      height: 0;
      animation: d-ferris-cabin $spin linear infinite;

      // the body, swinging gently on its hook
      &::before {
        content: '';
        position: absolute;
        top: 3px;
        left: -9px;
        width: 18px;
        height: 15px;
        border-radius: 3px 3px 7px 7px;
        background: linear-gradient(
          var(--c) 0 30%,
          color-mix(in srgb, var(--c) 30%, #fff) 30% 62%,
          var(--c) 62%
        );
        box-shadow: 0 0 8px color-mix(in srgb, var(--c) 50%, transparent);
        transform-origin: 50% -3px;
        animation: d-ferris-swing 1.6s ease-in-out infinite alternate;
      }

      // the hook
      &::after {
        content: '';
        position: absolute;
        top: -1px;
        left: -1px;
        width: 2px;
        height: 5px;
        background: color-mix(in srgb, var(--text) 70%, transparent);
      }
    }

    @for $n from 1 through 8 {
      b:nth-of-type(#{$n}) {
        --a: #{($n - 1) * 45deg};
        --c: #{list.nth($colors, ($n - 1) % 4 + 1)};

        &::before {
          animation-delay: #{$n * -0.4s};
        }
      }
    }

    // the hub, in front of the front rim
    u {
      position: absolute;
      inset: calc(50% - 8px);
      border-radius: 50%;
      background: radial-gradient(circle at 35% 35%, #fff, var(--warm) 55%, var(--hot));
      transform: translateZ($depth + 1px);
    }
  }
}

@keyframes d-ferris-spin {
  to {
    transform: rotate(360deg);
  }
}

@keyframes d-ferris-cabin {
  from {
    transform: rotate(var(--a)) translateY(-$r) rotate(calc(var(--a) * -1)) rotate(0deg);
  }

  to {
    transform: rotate(var(--a)) translateY(-$r) rotate(calc(var(--a) * -1)) rotate(-360deg);
  }
}

@keyframes d-ferris-swing {
  from {
    transform: rotate(-5deg);
  }

  to {
    transform: rotate(5deg);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.