Introduction
This headline reads as a single word floating between two layers of moving water, breaking as the near wave crosses in front of it, but the whole scene is built from two flat SVGs and one HTML heading. There is no video, no canvas, and no per-frame JavaScript loop — every wave is a static path generated once at load, animated purely with a looping CSS transform. The illusion comes from stacking order: a hazy distant swell behind the text, the text itself, then a nearer crest drawn in front, so depth is a z-index decision rather than a rendering trick.
Three responsibilities stay separate the whole way through: the wave path generator only ever produces a shape, and knows nothing about the headline. The headline is a real semantic h1 positioned in its own layer, and knows nothing about the waves either. The only place the two ever meet is the reflection mask, which borrows the wave's own path to decide where a ghost copy of the headline is allowed to show through.
How to generate a wave path from a handful of sine harmonics with integer cycle counts so the tile loops with no visible seam — how to layer two independent wave SVGs on either side of a headline to fake depth — how to reuse a wave's own path inside an SVG mask so a text reflection ripples in perfect sync with the wave that produces it — and how to add a small continuous rotate/translate wobble so static type reads as something afloat rather than something animated.
Step 1 — A Wave That Loops Without a Seam
Each wave layer is a sum of two or three sine terms, each with an INTEGER number of cycles across the tile width. Because a sine function always returns to the same value after a whole number of periods, f(0) and f(width) are guaranteed to match exactly — so a second copy of the same tile can sit directly beside the first with no visible join. Amplitude and phase are randomised per layer on load, so the curve looks hand-drawn rather than mechanically repeating, while the loop itself stays mathematically perfect.
function makeWave(baseY, harmonics) {
const terms = harmonics.map(h => ({
k: h.k,
amp: h.amp * rand(.75, 1.2),
phase: rand(0, Math.PI * 2)
}));
return x => terms.reduce(
(y, t) => y - t.amp * Math.sin((2 * Math.PI * t.k * x) / TILE_W + t.phase),
baseY
);
}
// sample two tiles wide so translateX(-TILE_W) has nothing to jump over
function crestLine(fn, step = 8) {
let d = `M0,${fn(0).toFixed(1)}`;
for (let x = step; x <= TILE_W * 2; x += step) d += ` L${x},${fn(x).toFixed(1)}`;
return d;
}Once a path exists, the whole layer scrolls with a single CSS keyframe rather than a rAF loop — translateX from 0 to -TILE_W, linear, infinite. Because the path was sampled two tiles wide, the moment the animation completes one full tile of travel it's visually indistinguishable from the frame it started on, and the loop restarts invisibly.
@keyframes wave-scroll {
from { transform: translateX(0); }
to { transform: translateX(-1440px); }
}
.wave-drift { animation: wave-scroll linear infinite; }Step 2 — Depth as a Stacking Decision
Two separate <svg> elements share the exact same viewBox and are positioned absolutely over one another. The first, lower z-index, holds a slow, low-amplitude distant swell. The headline sits above that in its own stacking context. The second svg, a higher z-index, holds only the near crest — a taller, faster, higher-amplitude wave whose opaque fill is drawn on top of everything beneath it. Because both svgs share one coordinate space, nudging the headline's vertical position is enough to control exactly how much of its lower half the near wave appears to swallow.
.hero__waves--back { z-index: 1; } /* behind the headline */
.hero__content { z-index: 2; } /* the headline itself */
.hero__waves--front { z-index: 3; } /* in front, breaks over it */The near wave doesn't need to know the headline exists — it just needs to be drawn after it. Depth here is document order, not physics.
Step 3 — Borrowing the Wave’s Own Path as a Mask
The reflection is a second, mirrored copy of the headline text drawn as SVG <text>, tinted and set to low opacity. On its own it would just be a rectangle of text sitting wherever it's placed. An SVG <mask> filled with the exact same body path as the visible wave — animated with the identical CSS class and duration — is applied over it, so only the portion of the reflection that falls inside the water's current shape is ever visible. Because the mask and the wave share one animation, they can never drift out of phase with each other.
const mask = el('mask', { id: 'waterMask' });
const maskGroup = el('g', { class: 'wave-drift' });
maskGroup.style.animationDuration = animDuration; // same duration as the visible wave
maskGroup.appendChild(el('path', { d: fillD, fill: '#fff' }));
mask.appendChild(maskGroup);
const reflection = el('g', { mask: 'url(#waterMask)', opacity: .22 });
reflection.appendChild(mirroredHeadlineText);Generating a second, independent wave for the mask would drift out of sync within a few cycles — two separate random amplitudes and durations can never stay locked together. Reusing the same path data and the same animation class is what guarantees the reflection ripples exactly as the wave above it does, indefinitely.
Step 4 — A Small, Continuous Rock
The headline runs two animations at once: a one-time entrance easing it up into place, and a second, infinite keyframe layered on top that gently rotates and lifts it by a few pixels on a slow six-and-a-half-second cycle. The amounts are deliberately tiny — a degree of rotation, several pixels of lift — so it reads as something resting on moving water rather than something visibly animating. Both keyframes are listed in the same animation property, comma-separated, so they run independently without one overwriting the other.
.headline {
animation:
rise .8s cubic-bezier(.2,.7,.2,1) .22s forwards,
headline-wobble 6.5s ease-in-out 1s infinite;
}
@keyframes headline-wobble {
0%, 100% { transform: translateY(0) rotate(-.6deg); }
50% { transform: translateY(-6px) rotate(.6deg); }
}
@media (prefers-reduced-motion: reduce) {
.headline { animation: none; opacity: 1; }
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| harmonics per layer | 2–3 | How irregular the crest looks; more terms read as choppier water, fewer as a smoother swell |
| tile width (TILE_W) | 1440px | Distance the wave travels before its loop repeats — must match translateX distance exactly |
| back layer duration | 38–52s | Slow drift for the distant swell, reinforcing that it sits further away |
| front layer duration | 26s | Faster drift for the near crest, the layer closest to the viewer |
| reflection opacity | .22 | How visible the masked headline echo is against the water fill beneath it |
| wobble amplitude | ±6px / ±.6deg | How much the headline itself rocks; kept small so it reads as afloat, not animated |
Full Source Code
The complete piece is a single self-contained HTML document — the two procedural wave SVGs, the masked reflection, the headline and its wobble, and nothing else. No build step, no external assets, no animation library.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/wave-surf-headline.html