Pure CSS Loaders & patterns #loop #loader #pendulum #physics #wave

Pendulum wave in pure CSS

Fifteen pendulums hang from one frame, each a little shorter and faster than the last, so the row drifts through snakes, waves and two crossing lines before it falls back into one. Pendulum k swings 20 + k times in the same 30 seconds, so the whole pattern repeats exactly.

  • 141 lines of CSS
  • 23 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The whole trick is in the durations. Pendulum k swings 20 + k times in one 30-second loop, so its animation-duration is calc(30s / (20 + var(--k))): every one finishes a whole number of swings in the same 30 s, which is the moment they all hang in one line again. In between, each one slips a little further behind its faster neighbour, and that steady slip is what draws the snakes and the waves. At 15 s every pendulum is halfway through its count, so all fifteen pass the middle together, every other one going the opposite way, and the row splits into two that cross.
  2. One @keyframes block is a full swing, out and back on both sides: out to each end on a sine ease-out (a pendulum slows to a stop at the top), back to the middle on a sine ease-in (it gathers speed on the way down). Joined that way the angle follows a true sine in time, and 0% and 100% are both the pose hanging straight, so the loop has no seam and a paused card shows a calm, aligned row.
  3. Each pendulum is a wrapper turned with rotateX about its transform-origin on the bar, so the threads and the bob swing as one rigid thing across the bar. Its two threads are the ::before and ::after, a V spread along the bar as on the real device, where it keeps each bob swinging in its own plane.
  4. A bob is a flat disc painted as a sphere, which would turn edge-on as it swings. So it runs a second animation with the same duration and easing that turns it back by exactly the swing, then undoes the camera turn of the whole rig: whatever the pendulum does, the disc stays square to you and the highlight stays upper left.
  5. The lengths are in true proportion: a period goes as the square root of the length, so pendulum k is 150 × (20 / (20 + k))² units long, from 150 at the far end to 52 at the near one. Every length is a multiple of one base unit, --u, tied to the canvas, so the frame is the same share of a gallery card, the editor and a recording canvas.

Key techniques

  • duration = loop ÷ (20 + k)
  • sine ease-out then ease-in = a pendulum
  • counter-rotation keeps each bob facing you
  • lengths ∝ period²

Copy-paste code

HTML 23 lines

<div class="scene">
  <div class="rig" role="img" aria-label="Loading: a pendulum wave, fifteen pendulums drifting in and out of step">
    <div class="box bar"></div>
    <div class="box post left"></div>
    <div class="box post right"></div>
    <div class="box base"></div>
    <div class="pend" style="--k:0; --L:150.0; --t:2.67deg; --tl:150.2; --c:#2ee6d6"><b class="bob"></b></div>
    <div class="pend" style="--k:1; --L:136.1; --t:2.95deg; --tl:136.2; --c:#3bd5dc"><b class="bob"></b></div>
    <div class="pend" style="--k:2; --L:124.0; --t:3.23deg; --tl:124.2; --c:#49c3e2"><b class="bob"></b></div>
    <div class="pend" style="--k:3; --L:113.4; --t:3.53deg; --tl:113.6; --c:#56b2e8"><b class="bob"></b></div>
    <div class="pend" style="--k:4; --L:104.2; --t:3.84deg; --tl:104.4; --c:#63a0ed"><b class="bob"></b></div>
    <div class="pend" style="--k:5; --L:96.0; --t:4.17deg; --tl:96.3; --c:#708ff3"><b class="bob"></b></div>
    <div class="pend" style="--k:6; --L:88.8; --t:4.51deg; --tl:89.0; --c:#7e7df9"><b class="bob"></b></div>
    <div class="pend" style="--k:7; --L:82.3; --t:4.86deg; --tl:82.6; --c:#8b6cff"><b class="bob"></b></div>
    <div class="pend" style="--k:8; --L:76.5; --t:5.23deg; --tl:76.9; --c:#9c68f1"><b class="bob"></b></div>
    <div class="pend" style="--k:9; --L:71.3; --t:5.60deg; --tl:71.7; --c:#ac63e3"><b class="bob"></b></div>
    <div class="pend" style="--k:10; --L:66.7; --t:5.99deg; --tl:67.0; --c:#bd5fd5"><b class="bob"></b></div>
    <div class="pend" style="--k:11; --L:62.4; --t:6.40deg; --tl:62.8; --c:#cd5ac7"><b class="bob"></b></div>
    <div class="pend" style="--k:12; --L:58.6; --t:6.81deg; --tl:59.0; --c:#de56b9"><b class="bob"></b></div>
    <div class="pend" style="--k:13; --L:55.1; --t:7.24deg; --tl:55.5; --c:#ee51ab"><b class="bob"></b></div>
    <div class="pend" style="--k:14; --L:51.9; --t:7.68deg; --tl:52.4; --c:#ff4d9d"><b class="bob"></b></div>
  </div>
</div>

CSS 141 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the frame is the same share of a
     gallery card, the editor, a full screen and a recording canvas */
  --u: 0.25vmin;
  perspective: calc(900 * var(--u));
}

/* the frame, seen from above and from the right, so the row of pendulums runs away from you to
   the left and their swings, across the bar, read as left and right on screen. The near corner
   of the base hangs low and right, so the rig is moved up and left to centre the drawing */
.rig {
  position: relative;
  width: calc(328 * var(--u));
  height: calc(194 * var(--u));
  transform-style: preserve-3d;
  transform: translate(calc(-15 * var(--u)), calc(-28 * var(--u))) rotateX(-13deg) rotateY(-50deg);
}

/* a box of the frame: the div is its front face, half its depth before the pendulums' plane;
   ::before is its top, laid back from its top edge, and ::after its right end, turned back from
   its right edge. Those three are the faces that look at the camera from here */
.box {
  --d: 10;
  position: absolute;
  background: #8b6cff;
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--d) / 2 * var(--u)));
}
.box::before,
.box::after {
  content: '';
  position: absolute;
}
.box::before {
  left: 0;
  right: 0;
  top: 0;
  height: calc(var(--d) * var(--u));
  background: #bda9ff;
  transform-origin: 50% 0;
  transform: rotateX(-90deg);
}
.box::after {
  top: 0;
  bottom: 0;
  left: 100%;
  width: calc(var(--d) * var(--u));
  background: #6a50d9;
  transform-origin: 0 50%;
  transform: rotateY(90deg);
}
.bar {
  left: 0;
  top: 0;
  width: calc(328 * var(--u));
  height: calc(10 * var(--u));
}
/* the posts start one unit up inside the bar, so two faces of one colour overlap rather than
   meet at an antialiased hairline; their tops are under the bar */
.post {
  top: calc(9 * var(--u));
  width: calc(10 * var(--u));
  height: calc(176 * var(--u));
}
.post::before { display: none; }
.post.left { left: 0; }
.post.right { left: calc(318 * var(--u)); }
/* the base: a slab as deep as the longest swing, its top the lightest face */
.base {
  --d: 104;
  left: calc(-10 * var(--u));
  top: calc(184 * var(--u));
  width: calc(348 * var(--u));
  height: calc(10 * var(--u));
  background: #5a44c4;
}
.base::before { background: #7660e6; }
.base::after { background: #4632a0; }

/* a pendulum: hung from the underside of the bar at its own place along it, origin at the top,
   so rotateX swings threads and bob together across the bar. Pendulum k makes 20 + k full swings
   in 30 s, so all fifteen are back in line every 30 s and never drift */
.pend {
  --a: 20;
  position: absolute;
  top: calc(10 * var(--u));
  left: calc((16 + var(--k) * 20) * var(--u));
  width: calc(16 * var(--u));
  height: calc((var(--L) + 9) * var(--u));
  transform-style: preserve-3d;
  transform-origin: 50% 0;
  animation: swing calc(30s / (20 + var(--k))) infinite;
}

/* the threads: a V tied 7 units either side along the bar, meeting at the bob; tilt and length
   are worked out from the pendulum's length (--t, --tl) */
.pend::before,
.pend::after {
  content: '';
  position: absolute;
  top: 0;
  width: calc(1.2 * var(--u));
  height: calc(var(--tl) * var(--u));
  background: currentColor;
  opacity: 0.5;
  transform-origin: 50% 0;
}
.pend::before { left: calc(1 * var(--u) - 0.6 * var(--u)); transform: rotate(calc(-1 * var(--t))); }
.pend::after { left: calc(15 * var(--u) - 0.6 * var(--u)); transform: rotate(var(--t)); }

/* the bob: a disc painted as a sphere. It turns back by the swing, in step with it, and then
   undoes the rig's own turn, so it always faces the camera */
.bob {
  position: absolute;
  top: calc((var(--L) - 9) * var(--u));
  left: calc(-1 * var(--u));
  width: calc(18 * var(--u));
  height: calc(18 * var(--u));
  border-radius: 50%;
  background: radial-gradient(circle at 36% 32%, #fff 0 8%, var(--c) 38%, color-mix(in srgb, var(--c), #000 55%) 100%);
  animation: face calc(30s / (20 + var(--k))) infinite;
}

/* a full swing: out on a sine ease-out (slowing to a stop), back on a sine ease-in (gathering
   speed), to one side and then the other. The angle follows a sine in time, and 0% and 100% are
   the same hanging pose */
@keyframes swing {
  0% { transform: rotateX(0deg); animation-timing-function: cubic-bezier(0.61, 1, 0.88, 1); }
  25% { transform: rotateX(calc(var(--a) * 1deg)); animation-timing-function: cubic-bezier(0.12, 0, 0.39, 0); }
  50% { transform: rotateX(0deg); animation-timing-function: cubic-bezier(0.61, 1, 0.88, 1); }
  75% { transform: rotateX(calc(var(--a) * -1deg)); animation-timing-function: cubic-bezier(0.12, 0, 0.39, 0); }
  100% { transform: rotateX(0deg); }
}
/* the same swing turned back, then the rig's rotateX(-13deg) rotateY(-50deg) undone */
@keyframes face {
  0% { transform: rotateX(0deg) rotateY(50deg) rotateX(13deg); animation-timing-function: cubic-bezier(0.61, 1, 0.88, 1); }
  25% { transform: rotateX(calc(var(--a) * -1deg)) rotateY(50deg) rotateX(13deg); animation-timing-function: cubic-bezier(0.12, 0, 0.39, 0); }
  50% { transform: rotateX(0deg) rotateY(50deg) rotateX(13deg); animation-timing-function: cubic-bezier(0.61, 1, 0.88, 1); }
  75% { transform: rotateX(calc(var(--a) * 1deg)) rotateY(50deg) rotateX(13deg); animation-timing-function: cubic-bezier(0.12, 0, 0.39, 0); }
  100% { transform: rotateX(0deg) rotateY(50deg) rotateX(13deg); }
}
Standalone snippet · plain CSS, no build step, no dependencies.