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.
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.
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.
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.
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.
.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.
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.
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.
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.
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);
});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.
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.
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
| Property | Example | Effect |
|---|---|---|
| ring count | 20 – 100 | How dense the ripple field looks; recalculated on resize |
| ripples | 3 – 5 | How many rolling groups the same rings resolve into |
| cycle (speed) | 2s – 0.25s | How fast each ring completes one wobble cycle |
| wobble translateY | ±0.6cqi | How far each ring rises and falls per cycle, scaled to the container |
| ring opacity | 1 − i/total | Outer rings fade toward transparent so the field has no hard edge |
| inset shadow pair | light + dark corners | Gives 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.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/water-ripples.htmlOne 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.