Pure CSS Scenes & objects #loop #scene #balloon #sky

Hot-air balloon in pure CSS

A striped balloon bobbing and turning slowly on its ropes, with a woven basket and clouds drifting past in front of it and behind it. Each of its twelve gores is six flat panels, each tilted to follow the balloon’s outline, so the silhouette is round from every side as the stripes go by.

  • 309 lines of CSS
  • 31 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. CSS has no curved surfaces, so the envelope is twelve gores, each a carrier at the axis turned rotateY(k × 30deg), and each gore is six flat panels following the outline down from the crown ring to the mouth. A panel is moved to the middle of its band with translate3d(0, y, r) and tilted with rotateX by the outline’s angle there, so its edges land exactly on its neighbours’ and the silhouette is round from every side.
  2. A panel is as wide as a side of a 12-sided ring at its radius, 2 r tan 15°, which differs at its top and bottom, so each is a trapezoid cut with clip-path. A clip flattens anything 3D inside an element, so only leaves are clipped: the panels are the ::before and ::after of the gore and of two plain <i> carriers inside it. Panels facing away are hidden with backface-visibility.
  3. There is no light in CSS 3D, so it is painted on, twice. Each band carries a gradient from white at the crown to shade at the skirt, meeting its neighbours at the same value; that light turns with the stripes. And a plane that does not turn lies just in front of the envelope, cut to its outline, with a sheen up on the left and shade round the edge: pushed toward you by translateZ(54 units) and shrunk by (800 − 54) ÷ 800 about the point the perspective looks from, it lands exactly on the balloon, so the stripes turn under a light that stays put.
  4. Ropes are two crossed lines each, so they never vanish edge-on; the basket is four woven walls (crossed repeating-linear-gradients) round a floor, turning with the balloon. It turns once every 40 s and bobs on a separate wrapper, so the two motions add up without either knowing of the other; the bob (10 s there and back), the burner and the clouds (20 and 40 s) all divide the turn, so the whole scene repeats exactly once a turn.
  5. The clouds drift at three depths, one in front of the balloon and two behind it, fading in and out with opacity, so their loops have no seam and they never reach the edge of the canvas. Every length is a multiple of one base unit, --u.

Key techniques

  • 12 gores × 6 panels, each turned rotateY(k × 30°)
  • panel: translate3d to its band, rotateX to the outline
  • trapezoid panels: clip-path on the leaves only
  • a light that does not turn: a plane in front, scaled to land on it

Copy-paste code

HTML 31 lines

<div class="scene">
  <div class="sky">
    <i class="cloud far" style="--y:18;--z:-160;--d:40s;--t:-20s"></i>
    <i class="cloud" style="--y:66;--z:-90;--d:40s;--t:-10s"></i>
    <div class="flight">
      <div class="balloon">
        <b class="crown"></b>
        <b class="gore" style="--k:0;--c:#8b6cff"><i></i><i></i></b>
        <b class="gore" style="--k:1;--c:#2ee6d6"><i></i><i></i></b>
        <b class="gore" style="--k:2;--c:#ff4d9d"><i></i><i></i></b>
        <b class="gore" style="--k:3;--c:#ffb547"><i></i><i></i></b>
        <b class="gore" style="--k:4;--c:#8b6cff"><i></i><i></i></b>
        <b class="gore" style="--k:5;--c:#2ee6d6"><i></i><i></i></b>
        <b class="gore" style="--k:6;--c:#ff4d9d"><i></i><i></i></b>
        <b class="gore" style="--k:7;--c:#ffb547"><i></i><i></i></b>
        <b class="gore" style="--k:8;--c:#8b6cff"><i></i><i></i></b>
        <b class="gore" style="--k:9;--c:#2ee6d6"><i></i><i></i></b>
        <b class="gore" style="--k:10;--c:#ff4d9d"><i></i><i></i></b>
        <b class="gore" style="--k:11;--c:#ffb547"><i></i><i></i></b>
        <b class="rope" style="--k:0"></b>
        <b class="rope" style="--k:1"></b>
        <b class="rope" style="--k:2"></b>
        <b class="rope" style="--k:3"></b>
        <b class="burner"></b>
        <div class="basket"><i></i><i></i><i></i><i></i></div>
      </div>
      <i class="shade"></i>
    </div>
    <i class="cloud near" style="--y:120;--z:70;--d:20s;--t:-14s"></i>
  </div>
</div>

CSS 309 lines

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

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

/* the sky: the balloon and the clouds share one 3D space, so a cloud can pass in front */
.sky {
  position: relative;
  width: calc(110 * var(--u));
  height: calc(144 * var(--u));
  transform-style: preserve-3d;
  /* the low cloud hangs under the basket: lifted this much, balloon and clouds together sit in
     the middle */
  transform: translateY(calc(-7 * var(--u)));
}

/* the bob, on its own wrapper, so it adds to the turn without either knowing of the other */
.flight {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  animation: bob 5s ease-in-out infinite alternate;
}

@keyframes bob {
  from { transform: translateY(calc(3 * var(--u))); }
  to   { transform: translateY(calc(-5 * var(--u))); }
}

/* the balloon's axis: a zero-size point at the crown, in the middle of the sky. Everything that
   turns is placed from it */
.balloon {
  position: absolute;
  left: 50%;
  top: 0;
  transform-style: preserve-3d;
  animation: turn 40s linear infinite;
}

@keyframes turn {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(360deg); }
}

/* the crown: a flat disc closing the top of the gores, laid level */
.crown {
  position: absolute;
  left: calc(-23.5 * var(--u));
  top: calc(-18.5 * var(--u));
  width: calc(47 * var(--u));
  height: calc(47 * var(--u));
  border-radius: 50%;
  background: conic-gradient(#8b6cff 0deg 90deg, #2ee6d6 90deg 180deg, #ff4d9d 180deg 270deg, #ffb547 270deg 360deg);
  transform: rotateX(90deg);
}

/* a gore: a carrier at the axis, turned to its place round the ring, with two more carriers in
   it (its <i>s, untransformed). None draws anything: the six panels are their pseudo-elements,
   because a panel is clipped and a clip flattens anything 3D inside it */
.gore,
.gore i {
  position: absolute;
  transform-style: preserve-3d;
}

.gore {
  transform: rotateY(calc(var(--k) * 30deg));
}

.gore::before,
.gore::after,
.gore i::before,
.gore i::after {
  content: '';
  position: absolute;
  backface-visibility: hidden; /* the far side's panels are not drawn through the near side */
}

/* the shoulder, out from the crown ring */
.gore::before {
  left: calc(-10.85 * var(--u));
  top: calc(-11.5 * var(--u));
  width: calc(21.7 * var(--u));
  height: calc(23 * var(--u));
  background: linear-gradient(rgb(255 255 255 / 0.30), rgb(255 255 255 / 0.20)), var(--c);
  clip-path: polygon(20.99% 0, 79.01% 0, 100% 100%, 0% 100%);
  transform: translate3d(0, calc(11.5 * var(--u)), calc(30.5 * var(--u))) rotateX(52.59deg);
}

/* the upper slope */
.gore::after {
  left: calc(-13.26 * var(--u));
  top: calc(-10.42 * var(--u));
  width: calc(26.52 * var(--u));
  height: calc(20.84 * var(--u));
  background: linear-gradient(rgb(255 255 255 / 0.20), rgb(255 255 255 / 0.09)), var(--c);
  clip-path: polygon(9.09% 0, 90.91% 0, 100% 100%, 0% 100%);
  transform: translate3d(0, calc(26.5 * var(--u)), calc(43.5 * var(--u))) rotateX(27.9deg);
}

/* the last of the upper half, almost upright */
.gore i:first-child::before {
  left: calc(-13.79 * var(--u));
  top: calc(-9.36 * var(--u));
  width: calc(27.59 * var(--u));
  height: calc(18.72 * var(--u));
  background: linear-gradient(rgb(255 255 255 / 0.09), rgb(255 255 255 / 0.00)), var(--c);
  clip-path: polygon(1.94% 0, 98.06% 0, 100% 100%, 0% 100%);
  transform: translate3d(0, calc(43.5 * var(--u)), calc(49 * var(--u))) rotateX(6.71deg);
}

/* under the equator, its top leaning out (a negative tilt) */
.gore i:first-child::after {
  left: calc(-13.79 * var(--u));
  top: calc(-11.24 * var(--u));
  width: calc(27.59 * var(--u));
  height: calc(22.48 * var(--u));
  background: linear-gradient(rgb(255 255 255 / 0.00), rgb(10 8 30 / 0.10)), var(--c);
  clip-path: polygon(0% 0, 100% 0, 94.17% 100%, 5.83% 100%);
  transform: translate3d(0, calc(62 * var(--u)), calc(47 * var(--u))) rotateX(-16.7deg);
}

/* the taper */
.gore i + i::before {
  left: calc(-12.19 * var(--u));
  top: calc(-11.5 * var(--u));
  width: calc(24.38 * var(--u));
  height: calc(23 * var(--u));
  background: linear-gradient(rgb(10 8 30 / 0.10), rgb(10 8 30 / 0.22)), var(--c);
  clip-path: polygon(0% 0, 100% 0, 85.71% 100%, 14.29% 100%);
  transform: translate3d(0, calc(80.5 * var(--u)), calc(37.5 * var(--u))) rotateX(-37.41deg);
}

/* the skirt, narrowing to the mouth */
.gore i + i::after {
  left: calc(-8.71 * var(--u));
  top: calc(-10.4 * var(--u));
  width: calc(17.41 * var(--u));
  height: calc(20.81 * var(--u));
  background: linear-gradient(rgb(10 8 30 / 0.22), rgb(10 8 30 / 0.34)), var(--c);
  clip-path: polygon(0% 0, 100% 0, 76.92% 100%, 23.08% 100%);
  transform: translate3d(0, calc(95 * var(--u)), calc(23.5 * var(--u))) rotateX(-51.34deg);
}

/* the light, which does not turn: a sheen up and to the left and shade round the edge, on a
   plane just in front of the envelope, cut to its outline. It is pushed toward you and shrunk by
   the same ratio about the point the perspective looks from, so it lands exactly on the balloon */
.shade {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(30% 22% at 38% 22%, rgb(255 255 255 / 0.34), transparent),
    radial-gradient(48% 40% at 47% 37%, transparent 52%, rgb(12 8 40 / 0.42));
  clip-path: polygon(70% 3.47%, 85.45% 12.5%, 93.64% 24.31%, 95.45% 36.11%, 90% 50%, 78.18% 61.81%, 64.55% 70.14%, 35.45% 70.14%, 21.82% 61.81%, 10% 50%, 4.55% 36.11%, 6.36% 24.31%, 14.55% 12.5%, 30% 3.47%);
  transform-origin: 50% 50%;
  transform: translateZ(calc(54 * var(--u))) scale(0.93);
}

/* a rope: from the skirt down to a corner of the basket, at 45° between the gores. A second
   line crossed through it (::before), so it never vanishes edge-on */
.rope {
  position: absolute;
  left: calc(-0.5 * var(--u));
  top: calc(-14.01 * var(--u));
  width: calc(1 * var(--u));
  height: calc(28.01 * var(--u));
  background: #9d7f5c;
  transform-style: preserve-3d;
  transform: rotateY(calc(45deg + var(--k) * 90deg)) translate3d(0, calc(114 * var(--u)), calc(15.28 * var(--u))) rotateX(1.14deg);
}

.rope::before {
  content: '';
  position: absolute;
  inset: 0;
  background: inherit;
  transform: rotateY(90deg);
}

/* the burner's flame, under the mouth: two crossed teardrops, flickering */
.burner {
  position: absolute;
  left: calc(-4 * var(--u));
  top: calc(104 * var(--u));
  width: calc(8 * var(--u));
  height: calc(12 * var(--u));
  transform-style: preserve-3d;
  transform-origin: 50% 100%;
  animation: flare 0.5s ease-in-out infinite alternate;
}

.burner::before,
.burner::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50% 50% 50% 50% / 70% 70% 30% 30%;
  background: radial-gradient(60% 50% at 50% 72%, #fffbe8, #ffd36b 40%, #ff8a3d 70%, rgb(255 77 157 / 0.6));
}

.burner::after {
  transform: rotateY(90deg);
}

@keyframes flare {
  from { transform: scale(0.9, 0.8); }
  to   { transform: scale(1, 1.1); } /* scale only: opacity on it would flatten its crossed plane */
}

/* the basket: a zero-size point under the axis; four woven walls stand round it */
.basket {
  position: absolute;
  top: calc(128 * var(--u));
  transform-style: preserve-3d;
}

.basket i {
  position: absolute;
  left: calc(-11 * var(--u));
  top: 0;
  width: calc(22 * var(--u));
  height: calc(16 * var(--u));
  border-top: calc(2.4 * var(--u)) solid #5e3a1f;
  border-radius: 0 0 calc(2 * var(--u)) calc(2 * var(--u));
  /* the weave: light strands across, darker ones up, over the wicker's own brown */
  background:
    repeating-linear-gradient(90deg, rgb(70 40 18 / 0.45) 0 calc(0.8 * var(--u)), transparent calc(0.8 * var(--u)) calc(3.6 * var(--u))),
    repeating-linear-gradient(rgb(255 225 170 / 0.35) 0 calc(1.4 * var(--u)), transparent calc(1.4 * var(--u)) calc(2.8 * var(--u))),
    linear-gradient(#c08a4e, #8a5a2e);
  transform: rotateY(calc(var(--k, 0) * 90deg)) translateZ(calc(11 * var(--u)));
}

.basket i:nth-child(2) { --k: 1; }
.basket i:nth-child(3) { --k: 2; }
.basket i:nth-child(4) { --k: 3; }

/* the floor, seen inside from above and under from below */
.basket::before {
  content: '';
  position: absolute;
  left: calc(-11 * var(--u));
  top: calc(5 * var(--u));
  width: calc(22 * var(--u));
  height: calc(22 * var(--u));
  background: #4a2c16;
  transform: rotateX(90deg);
}

/* a cloud: a long base and two puffs, one gradient across all three (sized to the whole cloud and
   shifted by each piece's top), so it reads as one body: bright above, lilac in its belly, which
   keeps it visible on a light stage. It drifts across at its own depth and fades at both ends */
.cloud,
.cloud::before,
.cloud::after {
  background: linear-gradient(#ffffff 22%, #dfe1f7 60%, #a9afe0) 0 var(--gy, calc(-14 * var(--u))) / 100% calc(30 * var(--u));
}

/* the element is the base, the bottom 16 of the cloud's 30 units */
.cloud {
  position: absolute;
  left: calc(50% - calc(30 * var(--u)));
  top: calc((var(--y) + 14) * var(--u));
  width: calc(60 * var(--u));
  height: calc(16 * var(--u));
  border-radius: 999px;
  opacity: 0;
  animation: drift var(--d) linear var(--t) infinite;
}

/* the two puffs on its back, their feet on its bottom edge */
.cloud::before,
.cloud::after {
  content: '';
  position: absolute;
  bottom: 0;
  border-radius: 50%;
}

.cloud::before {
  --gy: calc(-6 * var(--u));
  left: calc(7 * var(--u));
  width: calc(26 * var(--u));
  height: calc(24 * var(--u));
}

.cloud::after {
  --gy: calc(0 * var(--u));
  left: calc(25 * var(--u));
  width: calc(28 * var(--u));
  height: calc(30 * var(--u));
}

.cloud.far { --s: 0.8; }
.cloud.near { --s: 0.9; }

@keyframes drift {
  0%   { opacity: 0; transform: translate3d(calc(62 * var(--u)), 0, calc(var(--z) * var(--u))) scale(var(--s, 1)); }
  18%  { opacity: 0.94; }
  82%  { opacity: 0.94; }
  100% { opacity: 0; transform: translate3d(calc(-62 * var(--u)), 0, calc(var(--z) * var(--u))) scale(var(--s, 1)); }
}
Standalone snippet · plain CSS, no build step, no dependencies.