Aduok Code

A Realistic Water Ripple Field, Built with JS-Generated Ovals, Container Query Units, and a Staggered Wobble

Introduction

This scene reads as a still surface of water disturbed by a stone — a field of thin, wobbling ovals spreading outward, catching a warm highlight the way real ripples catch a low sun. Every ring is the same plain div: a border, a size, a delay. Nothing is drawn by hand and nothing is a preprocessor loop — the whole field is produced by one small JavaScript function that runs once on load and again on resize.

The technique rests on four ideas: each ring is a border-radius: 50% oval sized as a percentage of a shared container, so the whole field scales together — every ring shares one wobble keyframe, and looks different from its neighbors only because its width, opacity, and animation-delay are set inline by JavaScript, not because it has its own keyframes — the rings are staggered into rolling groups with a single formula (delay as a function of ring index, total ring count, and how many ripple groups you want visible at once), so the field looks like several waves rolling outward together instead of one ring pulsing alone — and the ring count itself is recalculated from the viewport width on every resize, so the field stays dense on a wide screen and readable on a narrow one without a stack of hand-written media queries.

What you will learn

How to generate a whole field of animated rings from one JavaScript loop instead of a Sass @for — how container query units (cqi) let every ring size itself relative to its parent instead of the viewport, so the effect drops cleanly into any container — how one delay formula turns identical rings into rolling groups of ripples — how to swap checkbox/radio "CSS-only" toggles for real buttons with actual state, without losing the all-CSS animation underneath — and why a light, sunlit blue reads as water far better than teal or navy on their own.

01
Part One
Rings Built by JavaScript, Not a Sass Loop

Step 1 — One Function, Any Number of Rings

The original version of this effect used a Sass @for loop to stamp out a fixed 100 nth-child rules, then hid the extras with a stack of media queries, one per 100px breakpoint. That works, but it bakes the ring count into the stylesheet at build time. Swapping the loop for a small JavaScript function does the same job at runtime: read how many rings you want, clear whatever is already there, and append that many fresh .ring divs, each with its own inline width, opacity, and animation-delay.

JavaScript
function buildRings(container, total, ripples, cycleSeconds) {
  container.querySelectorAll('.ring').forEach(r => r.remove());
  const frag = document.createDocumentFragment();

  for (let i = 1; i <= total; i++) {
    const ring = document.createElement('div');
    ring.className = 'ring';
    ring.style.width = (i / total) * 100 + 'cqi';
    ring.style.opacity = (1 - i / total).toFixed(3);
    ring.style.animationDuration = cycleSeconds + 's';
    frag.appendChild(ring);
  }
  container.appendChild(frag);
}

Because the rings are plain siblings with no nth-child dependency, adding, removing, or reordering one never risks breaking a selector further down the stylesheet — the only thing that changes per ring is set directly on that ring's own style attribute.

02
Part Two
Sizing and Fading Each Ring

Step 2 — cqi Units Instead of vw

Each ring is an oval — aspect-ratio: 3 flattens a circle into the wide, shallow shape a ripple has when viewed from above at a slight angle — sized in cqi rather than vw. Setting container-type: inline-size on the wrapping .field element means every ring's width is a percentage of that container specifically, not the whole viewport, so the same markup drops into a sidebar widget or a full-bleed hero and scales correctly either way.

CSS
.field {
  container-type: inline-size;
}

.ring {
  position: absolute;
  aspect-ratio: 3;
  border-radius: 50%;
  border: 1px solid rgba(220, 238, 252, 0.8);
  box-shadow:
    inset 3px -3px 10px rgba(255, 255, 255, 0.22),
    inset -2px 2px 8px rgba(3, 18, 30, 0.4);
  animation: wobble var(--cycle) ease-in-out infinite alternate;
}

@keyframes wobble {
  0%   { transform: translateY(0.6cqi) scaleY(0.94); }
  100% { transform: translateY(-0.6cqi) scaleY(1.06); }
}

The inset shadow pair is doing more for realism than the gradient background ever could — a light highlight on one corner and a dark shadow on the opposite corner is what makes a flat oval read as a raised, wet edge catching the light.

03
Part Three
One Delay Formula, Many Ripples

Step 3 — Turning Identical Rings Into Rolling Groups

Every ring shares one wobble animation and one duration, so on its own the whole field would just pulse in unison. The illusion of several ripples rolling outward comes entirely from animation-delay, offset by a single formula that spreads rings evenly across however many ripple "groups" you want visible at once.

JavaScript
const delay = -2 * cycleSeconds * ripples +
              (2 * cycleSeconds / (total / ripples)) * i;

ring.style.animationDelay = delay.toFixed(3) + 's';

Raise ripples and the same 60 rings resolve into more, tighter bands rolling outward; lower it and the field reads as one broad, slow swell. Nothing about the ring markup or the wobble keyframe changes — the entire feel of the water is tuned from this one line.

04
Part Four
A Floating Deck for Blur, Count, and Speed

Step 4 — Real Buttons Instead of Hidden Checkboxes

An earlier draft of this effect drove every control off hidden checkbox and radio inputs, paired with sibling selectors like #r4:checked ~ .scene. It works without JavaScript, but every new option means another hidden input and another chain of selectors to keep in sync. Since the rings are already JS-driven, the controls are simpler as an actual state object — one render() call updates a CSS class for blur, rebuilds the rings for a new ripple count, and rewrites --cycle for a new speed, all from ordinary button clicks.

JavaScript
blurSwitch.addEventListener('click', () => {
  state.blurred = !state.blurred;
  field.classList.toggle('is-blurred', state.blurred);
});

rippleStepper.addEventListener('click', (e) => {
  const dir = Number(e.target.dataset.dir);
  state.ripples = clamp(state.ripples + dir, 3, 5);
  buildRings(field, state.ringCount, state.ripples, state.cycleSeconds);
});
A gotcha worth knowing about

Early on, the blur toggle and the ripple-count stepper both called their own re-render function, and for a moment the field flickered between two ring counts on every click — the ripple stepper was rebuilding rings from a stale copy of state.ripples captured before the blur toggle's render finished. The fix was routing every control through one shared render() that always reads from a single state object, rather than letting each control keep its own local copy of the numbers it cares about.

05
Part Five
Redrawing the Field on Resize

Step 5 — Ring Count as a Function of Width

Rather than a media query per 100px breakpoint, ring count is one small function of window.innerWidth, clamped to a sensible floor and ceiling, and re-run on a debounced resize listener. Wider viewports get more rings and a denser field; narrow ones fall back to fewer, wider rings so the animation stays smooth instead of drawing a hundred one-pixel-wide ovals on a phone.

JavaScript
function ringCountForWidth(w) {
  return Math.max(20, Math.min(100, Math.round(w / 20)));
}

window.addEventListener('resize', () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(() => {
    state.ringCount = ringCountForWidth(window.innerWidth);
    buildRings(field, state.ringCount, state.ripples, state.cycleSeconds);
  }, 120);
});

Tuning Reference

PropertyExampleEffect
ring count20 – 100How dense the ripple field looks; recalculated on resize
ripples3 – 5How many rolling groups the same rings resolve into
cycle (speed)2s – 0.25sHow fast each ring completes one wobble cycle
wobble translateY±0.6cqiHow far each ring rises and falls per cycle, scaled to the container
ring opacity1 − i/totalOuter rings fade toward transparent so the field has no hard edge
inset shadow pairlight + dark cornersGives a flat oval the look of a raised, wet edge catching light

Full Source Code

The complete effect is a single self-contained HTML document — gradient backdrop, a JS-generated field of rings, and a floating control deck for blur, ripple count, and speed. No build step, no external assets.

HTML — water-ripples.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/water-ripples.html

One JavaScript function generating any number of ovals, one shared wobble keyframe, and a single delay formula turning identical rings into rolling groups. The whole field is well under 200 lines including the control deck, and not a single ring is written by hand.

Want to see it in action?Watch the full build on YouTube