Pure CSS Scenes & objects #loop #scene #building #night

Lighthouse in pure CSS

A striped tower of leaning strips on a rock in a night sea. Two long translucent planes, crossed, make each light beam; they sweep round with rotateY while a lit wedge on the water turns with them.

  • 251 lines of CSS
  • 55 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. Every round part is a frustum of leaning sides: rotateY(side) translateZ(bottom radius) rotateX(lean), with the lean atan((bottom − top) / height) and a trapezoid clip-path. Rock, striped tower, glass room and roof are the same recipe with other numbers.
  2. A light beam is two long tapering planes crossed like a flat X (±14° from level) with a gradient that fades out. The pair spins with rotateY, and the far beam passes behind the tower.
  3. The sea is a flat disc that clips its own wave lines. Each wave layer slides by exactly one pattern period and jumps back, so the loop is seamless. A lit wedge on the water turns with the beams: on a floor laid flat, rotate(-a) turns the same way as rotateY(a) above it.
  4. The browser sorts 3D planes by cutting them along each other’s infinite planes, and those cuts can show as shards. So the scene is three layers with the same camera painted in order (sea, rock, tower), which is always right with the camera above, and closed solids hide their back faces.
  5. Twice a turn a beam points at you; the halo at the lamp flares then, on a loop half as long as the sweep.

Key techniques

  • frustums of leaning strips
  • beam: two crossed clip-path planes, rotateY
  • sea: flat disc, wave lines slid by one period
  • rotateZ(-a) on the floor = rotateY(a) above it

Copy-paste code

HTML 55 lines

<div class="scene">
  <div class="lighthouse">
    <div class="layer"><div class="model">
      <div class="sea"><i></i><i></i><u></u></div>
    </div></div>
    <div class="layer"><div class="model">
      <div class="rock">
        <i style="--a:0deg;--d:0.06"></i>
        <i style="--a:45deg;--d:0.22"></i>
        <i style="--a:90deg;--d:0.40"></i>
        <i style="--a:135deg;--d:0.50"></i>
        <i style="--a:180deg;--d:0.46"></i>
        <i style="--a:225deg;--d:0.30"></i>
        <i style="--a:270deg;--d:0.12"></i>
        <i style="--a:315deg;--d:0.02"></i>
        <b></b>
      </div>
    </div></div>
    <div class="layer"><div class="model">
      <div class="tower">
        <i style="--a:0deg;--d:0.06"></i>
        <i style="--a:36deg;--d:0.18"></i>
        <i style="--a:72deg;--d:0.33"></i>
        <i style="--a:108deg;--d:0.45"></i>
        <i style="--a:144deg;--d:0.50"></i>
        <i style="--a:180deg;--d:0.46"></i>
        <i style="--a:216deg;--d:0.34"></i>
        <i style="--a:252deg;--d:0.19"></i>
        <i style="--a:288deg;--d:0.07"></i>
        <i style="--a:324deg;--d:0.02"></i>
      </div>
      <div class="gallery"></div>
      <div class="rail"></div>
      <div class="room">
        <i style="--a:0deg"></i>
        <i style="--a:60deg"></i>
        <i style="--a:120deg"></i>
        <i style="--a:180deg"></i>
        <i style="--a:240deg"></i>
        <i style="--a:300deg"></i>
        <b></b>
      </div>
      <div class="roof">
        <i style="--a:0deg;--d:0.06"></i>
        <i style="--a:60deg;--d:0.28"></i>
        <i style="--a:120deg;--d:0.48"></i>
        <i style="--a:180deg;--d:0.46"></i>
        <i style="--a:240deg;--d:0.24"></i>
        <i style="--a:300deg;--d:0.04"></i>
        <b></b>
      </div>
      <div class="beam"><i></i><i></i><i></i><i></i></div>
    </div></div>
  </div>
</div>

CSS 251 lines

/* the root is flat (three layers painted in order), so it holds the perspective itself */
.lighthouse {
  position: relative;
  width: 220px;
  height: 200px;
  perspective: 800px;
}

.lighthouse * {
  box-sizing: border-box;
}

/* each layer carries the same camera */
.layer {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: rotateX(-22deg) rotateY(-24deg);
}

/* the model's origin: the middle of the sea */
.model,
.rock,
.tower,
.room,
.roof {
  position: absolute;
  transform-style: preserve-3d;
}

.model {
  left: 50%;
  top: 150px;
}

/* the sea: a flat disc; it may clip, nothing in it is 3D */
.sea {
  position: absolute;
  left: -110px;
  top: -110px;
  width: 220px;
  height: 220px;
  overflow: hidden;
  border: 1.5px solid rgb(46 230 214 / 0.4);
  border-radius: 50%;
  background:
    radial-gradient(circle, transparent 0 44px, rgb(255 255 255 / 0.5) 45px 47px, transparent 50px 54px, rgb(255 255 255 / 0.16) 55px 56px, transparent 58px),
    radial-gradient(circle, #3f3b94, #1e1c4a 72%);
  transform: rotateX(90deg);
}

/* wave lines broken into dashes by a mask; they slide by exactly one period */
.sea i {
  position: absolute;
  inset: -20px 0 0;
  background: repeating-linear-gradient(transparent 0 16px, rgb(46 230 214 / 0.55) 16px 17.5px, transparent 17.5px 20px);
  mask: repeating-linear-gradient(90deg, #000 0 22px, transparent 22px 36px);
  animation: waves 2.6s linear infinite;
}

.sea i + i {
  inset: -28px 0 0;
  background: repeating-linear-gradient(transparent 0 9px, rgb(160 140 255 / 0.6) 9px 10px, transparent 10px 28px);
  mask: repeating-linear-gradient(90deg, transparent 0 14px, #000 14px 30px, transparent 30px 46px);
  animation: waves-2 4.2s linear infinite;
}

/* light on the water under the beams, turning with them */
.sea u {
  position: absolute;
  inset: 0;
  background: conic-gradient(from 70deg, transparent, rgb(255 181 71 / 0.55) 20deg, transparent 40deg 180deg, rgb(255 181 71 / 0.55) 200deg, transparent 220deg);
  mask: radial-gradient(closest-side, transparent 14%, #000 34%, transparent 98%);
  animation: pool 8s linear infinite;
}

/* rock: 8 sides, radius 40 → 31 over 18px */
.rock i {
  position: absolute;
  left: -16.87px;
  top: -20.12px;
  width: 33.74px;
  height: 20.12px;
  clip-path: polygon(11.94% 0, 88.06% 0, 100% 100%, 0 100%);
  transform-origin: 50% 100%;
  /* turn to its side, step out to the bottom radius, lean in by atan((bottom - top) / height) */
  transform: rotateY(var(--a)) translateZ(40px) rotateX(26.57deg);
  backface-visibility: hidden;
  background:
    linear-gradient(rgb(6 8 26 / var(--d)), rgb(6 8 26 / var(--d))),
    radial-gradient(circle at 30% 30%, rgb(255 255 255 / 0.12) 0 3px, transparent 3.5px) 0 0 / 13px 11px,
    linear-gradient(#636885, #393c4f 80%, #114949);
}

/* the rock's flat top, an octagon */
.rock b {
  position: absolute;
  left: -31px;
  top: -49px;
  width: 62px;
  height: 62px;
  clip-path: polygon(29.3% 0, 70.7% 0, 100% 29.3%, 100% 70.7%, 70.7% 100%, 29.3% 100%, 0 70.7%, 0 29.3%);
  background: radial-gradient(circle at 40% 40%, #82869d, #636885 70%);
  transform: rotateX(90deg);
}

/* tower: 10 sides, radius 16 → 11 over 84px, standing on the rock (y = -18px) */
.tower i {
  position: absolute;
  left: -5.5px;
  top: -102.15px;
  width: 11px;
  height: 84.15px;
  clip-path: polygon(17.5% 0, 82.5% 0, 100% 100%, 0 100%);
  transform-origin: 50% 100%;
  /* turn to its side, step out to the bottom radius, lean in by atan((bottom - top) / height) */
  transform: rotateY(var(--a)) translateZ(16px) rotateX(3.41deg);
  backface-visibility: hidden;
  background:
    linear-gradient(rgb(6 8 26 / var(--d)), rgb(6 8 26 / var(--d))),
    repeating-linear-gradient(#e04285 0 14px, #f3efe7 14px 28px);
}

/* the front side: a door and a lit window */
.tower i:first-child {
  background:
    linear-gradient(#2a1c28, #2a1c28) 50% 100% / 6px 11px no-repeat,
    linear-gradient(#ffb547, #ffb547) 50% 45% / 4px 6px no-repeat,
    repeating-linear-gradient(#e04285 0 14px, #f3efe7 14px 28px);
}

.gallery,
.rail {
  position: absolute;
  left: -18px;
  top: -120px;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: radial-gradient(closest-side, #3a3e58 70%, #23263a);
  transform: rotateX(90deg);
}

.rail {
  top: -126px;
  border: 1.5px solid #a3a8c8;
  background: none;
}

/* lamp room: 6 glass panes (open: the far panes show through) */
.room i {
  position: absolute;
  left: -4.92px;
  top: -116px;
  width: 9.84px;
  height: 14px;
  clip-path: polygon(3.05% 0, 96.95% 0, 100% 100%, 0 100%);
  transform-origin: 50% 100%;
  /* turn to its side, step out to the bottom radius, lean in by atan((bottom - top) / height) */
  transform: rotateY(var(--a)) translateZ(8px) rotateX(0deg);
  background:
    linear-gradient(90deg, #2b2e44 0 1px, transparent 1px calc(100% - 1px), #2b2e44 0),
    rgb(255 181 71 / 0.4);
}

/* a flat halo at the lamp, level with the beams */
.room b {
  position: absolute;
  left: -16px;
  top: -16px;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: radial-gradient(closest-side, #fff 0 18%, #ffd08a 30%, rgb(255 181 71 / 0.45) 55%, transparent);
  transform: translateY(-109px) rotateX(90deg);
  animation: flash 4s ease-in-out infinite;
}

/* roof: a cone of 6 triangles */
.roof i {
  position: absolute;
  left: -6.65px;
  top: -132.28px;
  width: 13.3px;
  height: 16.28px;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
  transform-origin: 50% 100%;
  /* turn to its side, step out to the bottom radius, lean in by atan((bottom - top) / height) */
  transform: rotateY(var(--a)) translateZ(11px) rotateX(42.51deg);
  backface-visibility: hidden;
  background:
    linear-gradient(rgb(6 8 26 / var(--d)), rgb(6 8 26 / var(--d))),
    linear-gradient(#5f53a3, #1d2033);
}

.roof b {
  position: absolute;
  left: -3px;
  top: -132px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 35%, #fff, #8a8fb0);
}

/* two beams, each two tapering planes crossed at ±14deg around the beam's axis */
.beam {
  position: absolute;
  top: -109px;
  transform-style: preserve-3d;
  animation: sweep 8s linear infinite;
}

.beam i {
  position: absolute;
  left: 4px;
  top: -17px;
  width: 104px;
  height: 34px;
  clip-path: polygon(0 42%, 100% 0, 100% 100%, 0 58%);
  background: linear-gradient(90deg, #ffe2b0, rgb(255 181 71 / 0.45) 30%, transparent 97%);
  transform-origin: -4px 50%; /* the lamp */
  transform: rotateX(76deg);
}

.beam i:nth-child(2) { transform: rotateX(104deg); }
.beam i:nth-child(3) { transform: rotateY(180deg) rotateX(76deg); }
.beam i:nth-child(4) { transform: rotateY(180deg) rotateX(104deg); }

@keyframes sweep {
  to { transform: rotateY(360deg); }
}

/* on the floor, rotate(-a) turns the same way as rotateY(a) above it */
@keyframes pool {
  to { transform: rotate(-360deg); }
}

@keyframes waves {
  to { transform: translateY(20px); }
}

@keyframes waves-2 {
  to { transform: translateY(28px); }
}

/* half a sweep: a beam faces you at about 114deg and 294deg of the turn */
@keyframes flash {
  0%, 45%, 82%, 100% { transform: translateY(-109px) rotateX(90deg) scale(1); opacity: 0.7; }
  63.3% { transform: translateY(-109px) rotateX(90deg) scale(2.2); opacity: 1; }
}

Sass source 299 lines

@use 'sass:math';

// Every round part is a frustum of $n leaning sides around the model's axis. A side stands on its
// bottom edge: rotateY(--a) → translateZ(bottom apothem) → rotateX(lean), with the lean
// atan((bottom - top) / height) bringing its top edge in to the top apothem. A top of 0 gives
// triangles, i.e. a cone. Sizes are unitless px; $y is the height the frustum stands on.
@mixin frustum-side($n, $bottom, $top, $h, $y, $closed: true) {
  $tan: math.tan(math.div(180deg, $n));
  $w: 2 * $bottom * $tan + 0.6; // a hair wider: no cracks between sides
  $w-top: 2 * $top * $tan;
  $side: math.sqrt($h * $h + ($bottom - $top) * ($bottom - $top));
  position: absolute;
  left: $w * -0.5px;
  top: ($y - $side) * 1px;
  width: $w * 1px;
  height: $side * 1px;
  transform-origin: 50% 100%;
  transform: rotateY(var(--a)) translateZ($bottom * 1px) rotateX(math.atan(math.div($bottom - $top, $h)));

  // a closed solid: its far sides never show, so they are not drawn (nothing to mis-sort)
  @if $closed {
    backface-visibility: hidden;
  }

  @if $top == 0 {
    clip-path: polygon(50% 0, 100% 100%, 0 100%);
  } @else {
    $in: math.div($w - $w-top, 2 * $w) * 100%;
    clip-path: polygon($in 0, 100% - $in 0, 100% 100%, 0 100%);
  }
}

// heights, from the sea (y = 0) up
$rock-h: 18;
$tower-top: $rock-h + 84; // 102: gallery
$room-top: $tower-top + 14; // 116: roof
$lamp: ($tower-top + 7) * -1px; // the light's centre
$sweep: 8s; // one turn of the beams
$shade: linear-gradient(rgb(6 8 26 / var(--d)), rgb(6 8 26 / var(--d)));
$stone: color-mix(in srgb, var(--muted) 45%, #3b3e55);

// Three layers with the same camera, painted in order: the sea, the rock, the tower with its
// light. The camera is above, so a lower part can never cover a higher one, and painting in
// order is always right. It also keeps the big sea plane and the rock's leaning sides out of the
// tower's depth sorting: the browser sorts 3D planes by cutting them along each other's
// (infinite) planes, and those cuts showed as shards on the tower. The root is flat, so it
// carries its own perspective.
.d-lighthouse {
  position: relative;
  width: 220px;
  height: 200px;
  perspective: 800px;

  &__layer {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    transform: rotateX(-22deg) rotateY(-24deg);
  }

  &__model {
    position: absolute;
    left: 50%;
    top: 150px;
    transform-style: preserve-3d;
  }

  // The sea: a flat disc. It clips its wave layers (a flat parent may clip: nothing in it is 3D).
  &__sea {
    position: absolute;
    left: -110px;
    top: -110px;
    width: 220px;
    height: 220px;
    overflow: hidden;
    border: 1.5px solid color-mix(in srgb, var(--accent-2) 40%, transparent);
    border-radius: 50%;
    background:
      radial-gradient(circle, transparent 0 44px, rgb(255 255 255 / 0.5) 45px 47px, transparent 50px 54px, rgb(255 255 255 / 0.16) 55px 56px, transparent 58px),
      radial-gradient(circle, color-mix(in srgb, var(--accent) 35%, #16205a), color-mix(in srgb, var(--accent) 18%, #060a22) 72%);
    transform: rotateX(90deg);

    // wave lines, broken into dashes by a mask, slid toward you by exactly one period
    i {
      position: absolute;
      inset: -20px 0 0;
      background: repeating-linear-gradient(transparent 0 16px, color-mix(in srgb, var(--accent-2) 55%, transparent) 16px 17.5px, transparent 17.5px 20px);
      mask: repeating-linear-gradient(90deg, #000 0 22px, transparent 22px 36px);
      animation: d-lighthouse-waves 2.6s linear infinite;
    }

    i + i {
      inset: -28px 0 0;
      background: repeating-linear-gradient(transparent 0 9px, color-mix(in srgb, var(--accent) 60%, #fff 10%) 9px 10px, transparent 10px 28px);
      mask: repeating-linear-gradient(90deg, transparent 0 14px, #000 14px 30px, transparent 30px 46px);
      animation-duration: 4.2s;
      animation-name: d-lighthouse-waves-2;
    }

    // light on the water under the two beams. On the floor, rotateZ(-a) turns the same way as
    // rotateY(a) above it (the floor's y axis points toward you).
    u {
      position: absolute;
      inset: 0;
      background: conic-gradient(
        from 70deg,
        transparent,
        color-mix(in srgb, var(--warm) 55%, transparent) 20deg,
        transparent 40deg 180deg,
        color-mix(in srgb, var(--warm) 55%, transparent) 200deg,
        transparent 220deg
      );
      mask: radial-gradient(closest-side, transparent 14%, #000 34%, transparent 98%);
      animation: d-lighthouse-pool $sweep linear infinite;
    }
  }

  &__rock,
  &__tower,
  &__room,
  &__roof {
    position: absolute;
    transform-style: preserve-3d;
  }

  &__rock {
    i {
      @include frustum-side(8, 40, 31, $rock-h, 0);
      background:
        $shade,
        radial-gradient(circle at 30% 30%, rgb(255 255 255 / 0.12) 0 3px, transparent 3.5px) 0 0 / 13px 11px,
        linear-gradient($stone, color-mix(in srgb, $stone 55%, #05060c) 80%, color-mix(in srgb, var(--accent-2) 30%, #05060c));
    }

    // the flat top: an octagon, laid flat
    b {
      position: absolute;
      left: -31px;
      top: -31px - $rock-h * 1px;
      width: 62px;
      height: 62px;
      clip-path: polygon(29.3% 0, 70.7% 0, 100% 29.3%, 100% 70.7%, 70.7% 100%, 29.3% 100%, 0 70.7%, 0 29.3%);
      background: radial-gradient(circle at 40% 40%, color-mix(in srgb, $stone 80%, #fff), $stone 70%);
      transform: rotateX(90deg);
    }
  }

  // red and white bands along each strip; the front strip also has the door and a lit window
  &__tower i {
    @include frustum-side(10, 16, 11, 84, -$rock-h);
    background:
      $shade,
      repeating-linear-gradient(color-mix(in srgb, var(--hot) 85%, #300) 0 14px, #f3efe7 14px 28px);

    &:first-child {
      background:
        linear-gradient(#2a1c28, #2a1c28) 50% 100% / 6px 11px no-repeat,
        linear-gradient(var(--warm), var(--warm)) 50% 45% / 4px 6px no-repeat,
        repeating-linear-gradient(color-mix(in srgb, var(--hot) 85%, #300) 0 14px, #f3efe7 14px 28px);
    }
  }

  &__gallery,
  &__rail {
    position: absolute;
    left: -18px;
    top: -18px - $tower-top * 1px;
    width: 36px;
    height: 36px;
    border-radius: 50%;
    background: radial-gradient(closest-side, #3a3e58 70%, #23263a);
    transform: rotateX(90deg);
  }

  // the railing's top rail: a thin ring a little higher
  &__rail {
    top: -18px - ($tower-top + 6) * 1px;
    border: 1.5px solid color-mix(in srgb, var(--text) 45%, #5a5f80);
    background: none;
  }

  // lamp room: six glass panes with mullions, and a flat halo of light at the lamp
  &__room {
    i {
      @include frustum-side(6, 8, 8, 14, -$tower-top, $closed: false); // glass: the far panes show through
      background:
        linear-gradient(90deg, #2b2e44 0 1px, transparent 1px calc(100% - 1px), #2b2e44 0),
        color-mix(in srgb, var(--warm) 40%, transparent);
    }

    b {
      position: absolute;
      left: -16px;
      top: -16px;
      width: 32px;
      height: 32px;
      border-radius: 50%;
      background: radial-gradient(closest-side, #fff 0 18%, color-mix(in srgb, var(--warm) 80%, #fff) 30%, color-mix(in srgb, var(--warm) 45%, transparent) 55%, transparent);
      // Flat, level with the beams. An upright glow facing the camera would be a plane cutting
      // through the tower, and the browser's 3D sorting shows such cuts as seams.
      transform: translateY($lamp) rotateX(90deg);
      animation: d-lighthouse-flash $sweep * 0.5 ease-in-out infinite;
    }
  }

  &__roof {
    i {
      @include frustum-side(6, 11, 0, 12, -$room-top);
      background: $shade, linear-gradient(color-mix(in srgb, var(--accent) 45%, #3a3e58), #1d2033);
    }

    b {
      position: absolute;
      left: -3px;
      top: -3px - ($room-top + 13) * 1px;
      width: 6px;
      height: 6px;
      border-radius: 50%;
      background: radial-gradient(circle at 35% 35%, #fff, #8a8fb0);
    }
  }

  // Two beams, each two planes tapering out from the lamp and crossed like a flat X around the
  // beam's axis (±14deg from level). The pair sweeps round with rotateY; the far beam passes
  // behind the tower. Why not one upright plane: the browser sorts 3D planes by cutting them
  // along each other's (infinite) planes, and an upright plane through the axis would slice
  // every tower strip, which shows as seams. These two stay above the tower's top.
  &__beam {
    position: absolute;
    top: $lamp;
    transform-style: preserve-3d;
    animation: d-lighthouse-sweep $sweep linear infinite;

    i {
      position: absolute;
      left: 4px;
      top: -17px;
      width: 104px;
      height: 34px;
      clip-path: polygon(0 42%, 100% 0, 100% 100%, 0 58%);
      background: linear-gradient(90deg, color-mix(in srgb, var(--warm) 60%, #fff) 0, color-mix(in srgb, var(--warm) 45%, transparent) 30%, transparent 97%);
      transform-origin: -4px 50%; // the lamp's axis
      transform: rotateX(76deg);
    }

    i:nth-child(2) {
      transform: rotateX(104deg);
    }

    i:nth-child(3) {
      transform: rotateY(180deg) rotateX(76deg);
    }

    i:nth-child(4) {
      transform: rotateY(180deg) rotateX(104deg);
    }
  }
}

@keyframes d-lighthouse-sweep {
  to {
    transform: rotateY(360deg);
  }
}

@keyframes d-lighthouse-pool {
  to {
    transform: rotate(-360deg);
  }
}

@keyframes d-lighthouse-waves {
  to {
    transform: translateY(20px);
  }
}

@keyframes d-lighthouse-waves-2 {
  to {
    transform: translateY(28px);
  }
}

// A beam points at the camera twice a turn: at about 114deg and 294deg of the sweep (the
// camera's direction in the model's frame). The lamp flares then, so this loop is half a turn.
@keyframes d-lighthouse-flash {
  0%,
  45%,
  82%,
  100% {
    transform: translateY($lamp) rotateX(90deg) scale(1);
    opacity: 0.7;
  }

  63.3% {
    transform: translateY($lamp) rotateX(90deg) scale(2.2);
    opacity: 1;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.