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.

  • 259 lines of CSS
  • 55 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

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.
  6. Every length is a multiple of one base unit, --u, tied to the canvas, and the frustum numbers are worked out in it: the rock is 80 units across and the tower stands 84 high on it, so the lighthouse is the same share of a gallery card, the editor and a recording canvas.

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 259 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the lighthouse is the same share
     of a gallery card, the editor, a full screen and a recording canvas */
  --u: 0.33vmin;
  display: grid;
  place-items: center;
}

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

.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: calc(150 * var(--u));
}

/* the sea: a flat disc; it may clip, nothing in it is 3D */
.sea {
  position: absolute;
  left: calc(-110 * var(--u));
  top: calc(-110 * var(--u));
  width: calc(220 * var(--u));
  height: calc(220 * var(--u));
  overflow: hidden;
  border: calc(1.5 * var(--u)) solid rgb(46 230 214 / 0.4);
  border-radius: 50%;
  background:
    radial-gradient(circle, transparent 0 calc(44 * var(--u)), rgb(255 255 255 / 0.5) calc(45 * var(--u)) calc(47 * var(--u)), transparent calc(50 * var(--u)) calc(54 * var(--u)), rgb(255 255 255 / 0.16) calc(55 * var(--u)) calc(56 * var(--u)), transparent calc(58 * var(--u))),
    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: calc(-20 * var(--u)) 0 0;
  background: repeating-linear-gradient(transparent 0 calc(16 * var(--u)), rgb(46 230 214 / 0.55) calc(16 * var(--u)) calc(17.5 * var(--u)), transparent calc(17.5 * var(--u)) calc(20 * var(--u)));
  mask: repeating-linear-gradient(90deg, #000 0 calc(22 * var(--u)), transparent calc(22 * var(--u)) calc(36 * var(--u)));
  animation: waves 2.6s linear infinite;
}

.sea i + i {
  inset: calc(-28 * var(--u)) 0 0;
  background: repeating-linear-gradient(transparent 0 calc(9 * var(--u)), rgb(160 140 255 / 0.6) calc(9 * var(--u)) calc(10 * var(--u)), transparent calc(10 * var(--u)) calc(28 * var(--u)));
  mask: repeating-linear-gradient(90deg, transparent 0 calc(14 * var(--u)), #000 calc(14 * var(--u)) calc(30 * var(--u)), transparent calc(30 * var(--u)) calc(46 * var(--u)));
  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 18 units */
.rock i {
  position: absolute;
  left: calc(-16.87 * var(--u));
  top: calc(-20.12 * var(--u));
  width: calc(33.74 * var(--u));
  height: calc(20.12 * var(--u));
  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(calc(40 * var(--u))) 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 calc(3 * var(--u)), transparent calc(3.5 * var(--u))) 0 0 / calc(13 * var(--u)) calc(11 * var(--u)),
    linear-gradient(#636885, #393c4f 80%, #114949);
}

/* the rock's flat top, an octagon */
.rock b {
  position: absolute;
  left: calc(-31 * var(--u));
  top: calc(-49 * var(--u));
  width: calc(62 * var(--u));
  height: calc(62 * var(--u));
  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 84 units, standing on the rock (y = -18) */
.tower i {
  position: absolute;
  left: calc(-5.5 * var(--u));
  top: calc(-102.15 * var(--u));
  width: calc(11 * var(--u));
  height: calc(84.15 * var(--u));
  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(calc(16 * var(--u))) 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 calc(14 * var(--u)), #f3efe7 calc(14 * var(--u)) calc(28 * var(--u)));
}

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

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

.rail {
  top: calc(-126 * var(--u));
  border: calc(1.5 * var(--u)) solid #a3a8c8;
  background: none;
}

/* lamp room: 6 glass panes (open: the far panes show through) */
.room i {
  position: absolute;
  left: calc(-4.92 * var(--u));
  top: calc(-116 * var(--u));
  width: calc(9.84 * var(--u));
  height: calc(14 * var(--u));
  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(calc(8 * var(--u))) rotateX(0deg);
  background:
    linear-gradient(90deg, #2b2e44 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(100% - calc(1 * var(--u))), #2b2e44 0),
    rgb(255 181 71 / 0.4);
}

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

/* roof: a cone of 6 triangles */
.roof i {
  position: absolute;
  left: calc(-6.65 * var(--u));
  top: calc(-132.28 * var(--u));
  width: calc(13.3 * var(--u));
  height: calc(16.28 * var(--u));
  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(calc(11 * var(--u))) 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: calc(-3 * var(--u));
  top: calc(-132 * var(--u));
  width: calc(6 * var(--u));
  height: calc(6 * var(--u));
  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: calc(-109 * var(--u));
  transform-style: preserve-3d;
  animation: sweep 8s linear infinite;
}

.beam i {
  position: absolute;
  left: calc(4 * var(--u));
  top: calc(-17 * var(--u));
  width: calc(104 * var(--u));
  height: calc(34 * var(--u));
  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: calc(-4 * var(--u)) 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(calc(20 * var(--u))); }
}

@keyframes waves-2 {
  to { transform: translateY(calc(28 * var(--u))); }
}

/* half a sweep: a beam faces you at about 114deg and 294deg of the turn */
@keyframes flash {
  0%, 45%, 82%, 100% { transform: translateY(calc(-109 * var(--u))) rotateX(90deg) scale(1); opacity: 0.7; }
  63.3% { transform: translateY(calc(-109 * var(--u))) rotateX(90deg) scale(2.2); opacity: 1; }
}
Standalone snippet · plain CSS, no build step, no dependencies.