Aduok Code

A Wave Surf Headline, Built from Two Procedural Sine-Wave Layers, a Masked Text Reflection, and a Wandering Idle Wobble

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.

What you will learn

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.

01
Part One
Two Seamless Sine-Harmonic Wave Layers

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.

JS
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.

CSS
@keyframes wave-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-1440px); }
}
.wave-drift { animation: wave-scroll linear infinite; }
02
Part Two
Sandwiching the Headline Between Them

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.

CSS
.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.

03
Part Three
A Masked Reflection Synced to the Crest

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.

JS
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);
Why reuse the path instead of a second wave

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.

04
Part Four
An Idle Wobble That Keeps It Afloat

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.

CSS
.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

PropertyExampleEffect
harmonics per layer2–3How irregular the crest looks; more terms read as choppier water, fewer as a smoother swell
tile width (TILE_W)1440pxDistance the wave travels before its loop repeats — must match translateX distance exactly
back layer duration38–52sSlow drift for the distant swell, reinforcing that it sits further away
front layer duration26sFaster drift for the near crest, the layer closest to the viewer
reflection opacity.22How visible the masked headline echo is against the water fill beneath it
wobble amplitude±6px / ±.6degHow 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.

HTML — wave-surf-headline.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/wave-surf-headline.html
Want to try it yourself?Open the live Wave Surf Headline file