Two Gradients and a Mask: A CSS Wave Toolkit

Wavy shapes have a reputation for being some of the most frustrating patterns to build in CSS. Developers often resort to border-radius hacks or a pile of magic numbers, resulting in something that looks approximately right only at certain sizes. While SVG is a solid fallback, CSS itself is capable of producing smooth, flexible waves with surprisingly little code.

The core method relies on just two radial gradients and the CSS mask property. These two tools are enough to create a wide range of wave shapes and patterns with full control over size and curvature — no trial-and-error needed once you understand the underlying geometry.

The Geometry of a Wave

There is no single geometric formula for a wave, but the shapes we want to re-create can be broken down into basic circle intersections. Start with two circles of equal radius, placed side by side:

Two gray circles.

If you draw a line that covers the top half of the first circle and the bottom half of the second, then repeat that line, a wave appears:

A squiggly red line in the shape of waves.

The logic scales up naturally. Imagine two circles with the same radius but offset vertically:

Two gray circles with two bisecting dashed lines indicating spacing.

The red trace no longer covers half of each circle, but a smaller arc segment that connects their intersection points. Repeating that trace gives a smoother, more substantial wave:

A red squiggly line.
A red wave pattern.

By controlling the radius of the circles and their vertical offset, you can generate almost any wave profile. These two variables map to S (size of the wave) and P (curvature), where P = m * S. The multiplier m lets you adjust curvature independently of wave size, and the simple case of m = 0 (where the circles are horizontally aligned) produces a clean sine-like wave. Values above 2 tend to flatten out, so the usable range is between 0 and 2.

The radius R of each circle is determined by both S and P, defined mathematically as:

R = sqrt(P² + S²)/2
R = sqrt(m²*S² + S²)/2
R = S*sqrt(m² + 1)/2

Building the Gradients

Since circles are involved, radial gradients are the natural tool. For the simplest scenario with the variable P equal to 0, the first gradient handles the main curve and the "body" of the wave:

.wave {
  --size: 50px;

  mask: radial-gradient(var(--size) at 50% 0%, #0000 99%, red 101%) 
    50% var(--size)/calc(4 * var(--size)) 100% repeat-x;
}

Here, --size sets the radius. The second gradient is a full circle that completes the wave path:

radial-gradient(var(--size) at 50% var(--size), blue 99%, #0000 101%) 
  calc(50% - 2*var(--size)) 0/calc(4 * var(--size)) 100%

When these are combined with the mask, the full CSS becomes:

.wave {
  --size: 50px;

  mask:
    radial-gradient(var(--size) at 50% var(--size),#000 99%, #0000 101%) 
      calc(50% - 2*var(--size)) 0/calc(4 * var(--size)) 100%,
    radial-gradient(var(--size) at 50% 0px, #0000 99%, #000 101%) 
      50% var(--size)/calc(4 * var(--size)) 100% repeat-x;
}

To make this work for any wave shape, the circles need to be offset. The first moves up and the second moves down, which requires introducing a new variable, --p:

.wave {
  --size: 50px;
  --p: 25px;

  mask:
    radial-gradient(var(--size) at 50% calc(var(--size) + var(--p)), #000 99%, #0000 101%) 
      calc(50% - 2*var(--size)) 0/calc(4 * var(--size)) 100%,
    radial-gradient(var(--size) at 50% calc(-1*var(--p)), #0000 99%, #000 101%) 
      50% var(--size) / calc(4 * var(--size)) 100% repeat-x;
}

Now, the circles no longer touch, and the wave vanishes. To fix it, the radius of each circle must be recalculated based on the distance between their centers. This is where the Pythagorean theorem comes in: if the offset moves each circle by var(--p), the distance between centers is twice that, requiring a radius of sqrt(2 * (--p * --p)):

R = sqrt(var(--size) * var(--size) + var(--p) * var(--p))

Applying that calc to the CSS brings the pattern back correctly:

.wave {
  --size: 50px;
  --p: 25px;
  --R: sqrt(var(--p) * var(--p) + var(--size)*var(--size));

  mask:
    radial-gradient(var(--R) at 50% calc(var(--size) + var(--p)), #000 99%, #0000 101%) 
      calc(50% - 2*var(--size)) 0 / calc(4 * var(--size)) 100%,
    radial-gradient(var(--R) at 50% calc(-1*var(--p)), #0000 99%, #000 101%) 
      50% var(--size)/calc(4 * var(--size)) 100% repeat-x;
}

This works visually, but for broader reusability, the curvature variable m comes into play, allowing us to set both the radius and the offset from a single configuration:

.wave {
  --size: 50px;
  --m: 0.5;
  --p: calc(var(--m)*var(--size));
  --R: calc(var(--size)*sqrt(var(--m)*var(--m) + 1));

  mask:
    radial-gradient(var(--R) at 50% calc(var(--size) + var(--p)), #000 99%, #0000 101%) 
      calc(50% - 2*var(--size)) 0 / calc(4 * var(--size)) 100%,
    radial-gradient(var(--R) at 50% calc(-1*var(--p)), #0000 99%, #000 101%) 
      50% var(--size)/calc(4 * var(--size)) 100% repeat-x;
}

This complete setup is the foundation for any wave you want. Adjusting just two custom properties changes both shape and curvature. The approach reliably replaces guesswork with a clean, mathematical system.

Changing Direction and Sides

The waves above fill the bottom of the element — like water. To flip the result so the wave appears on the top, only two positioning values change:

.wave {
  --size: 50px;
  --m: 0.5;
  --p: calc(var(--m)*var(--size));
  --R: calc(var(--size)*sqrt(var(--m)*var(--m) + 1));

  mask:
    radial-gradient(var(--R) at 50% calc(100% - (var(--size) + var(--p))), #000 99%, #0000 101%)
      calc(50% - 2 * var(--size)) 0/calc(4 * var(--size)) 100%,
    radial-gradient(var(--R) at 50% calc(100% + var(--p)), #0000 99%, #000 101%) 
      50% calc(100% - var(--size)) / calc(4 * var(--size)) 100% repeat-x;
}

However, you can use more intuitive keyword values instead of mathematical offsets. Relying on left, top, and bottom makes the code easier to read and learn:

.wave {
  --size: 50px;
  --m: 0.5;
  --p: calc(var(--m)*var(--size));
  --R: calc(var(--size)*sqrt(var(--m)*var(--m) + 1));

  mask:
    radial-gradient(var(--R) at left 50% bottom calc(var(--size) + var(--p)), #000 99%, #0000 101%) 
      calc(50% - 2 * var(--size)) 0/calc(4 * var(--size)) 100%,
    radial-gradient(var(--R) at left 50% bottom calc(-1 * var(--p)), #0000 99%, #000 101%) 
      left 50% bottom var(--size) / calc(4 * var(--size)) 100% repeat-x;
}

To apply waves on both top and bottom in a single statement, either use two independent properties or combine the gradients. When combining, reduce the height from 100% to 51% for each layer — the small extra percentage prevents gaps between the overlapping halves of the pattern:

.wave {
  --size: 50px;
  --m: 0.5;
  --p: calc(var(--m)*var(--size));
  --R: calc(var(--size)*sqrt(var(--m)*var(--m) + 1));

  mask:
    /* Gradient 1 */
    radial-gradient(var(--R) at left 50% bottom calc(var(--size) + var(--p)), #000 99%, #0000 101%) 
      left calc(50% - 2*var(--size)) bottom 0 / calc(4 * var(--size)) 51% repeat-x,
    /* Gradient 2 */
    radial-gradient(var(--R) at left 50% bottom calc(-1 * var(--p)), #0000 99%, #000 101%) 
      left 50% bottom var(--size) / calc(4 * var(--size)) calc(51% - var(--size)) repeat-x,
    /* Gradient 3 */
    radial-gradient(var(--R) at left 50% top calc(var(--size) + var(--p)), #000 99%, #0000 101%) 
      left calc(50% - 2 * var(--size)) top 0 / calc(4 * var(--size)) 51% repeat-x,
    /* Gradient 4 */
    radial-gradient(var(--R) at left 50% top calc(-1 * var(--p)), #0000 99%, #000 101%) 
      left 50% top var(--size) / calc(4 * var(--size)) calc(51% - var(--size)) repeat-x;
}

Following the same logic for right and left sides requires a simple swap of the positioning keywords and the X/Y axis values.

Turning it Into a Line

Up until now, the code has focused on filling area like water or sky. To draw thin, wavy lines, the gradients need to change from transparent-to-opaque (or opaque-to-transparent) into a sequence that includes both a start and end along the path. This requires a pattern of transparent → opaque → transparent:

#0000 calc(99% - var(--b)), #000 calc(101% - var(--b)) 99%, #0000 101%

Starting with the existing wave code and updating the color stops for line --b gives a messy result, but it clearly indicates where each curvature sits. To clean it up, both gradients need to be partial segments rather than full circles. After setting both to correctly produce the upper and lower curves, the next task is shrinking the area from full height to a smaller, fixed amount:

/* Size plus thickness */
calc(var(--size) + var(--b))

This value isn't mathematically exact; it just has to be suficiently large to contain the paths. The next adjustments center the gradients correctly vertically, pushing one down and the other up:

.wave {
  --size: 50px;
  --b: 10px;
  --m: 0.5;
  --p: calc(var(--m)*var(--size));
  --R: calc(var(--size)*sqrt(var(--m)*var(--m) + 1));

  --_g: #0000 calc(99% - var(--b)), #000 calc(101% - var(--b)) 99%, #0000 101%;  
  mask:
    radial-gradient(var(--R) at left 50% bottom calc(-1*var(--p)), var(--_g)) 
      calc(50% - 2*var(--size)) 50%/calc(4 * var(--size)) calc(var(--size) + var(--b)) no-repeat,
    radial-gradient(var(--R) at left 50% top calc(-1 * var(--p)), var(--_g)) 50%
      50%/calc(4 * var(--size)) calc(var(--size) + var(--b)) no-repeat;
}

Infine, notic a small radius correction: each gradient needs to shift up or down by half the border width (--b) to ensure their curves merge perfectly:

.wave {
  --size: 50px;
  --b: 10px;
  --m: 0.5;
  --p: calc(var(--m)*var(--size));
  --R: calc(var(--size)*sqrt(var(--m)*var(--m) + 1) + var(--b)/2);

  --_g: #0000 calc(99% - var(--b)), #000 calc(101% - var(--b)) 99%, #0000 101%;
  mask:
    radial-gradient(var(--R) at left 50% bottom calc(-1 * var(--p)), var(--_g)) 
     calc(50% - 2*var(--size)) calc(50% - var(--size)/2 - var(--b)/2) / calc(4 * var(--size)) calc(var(--size) + var(--b)) repeat-x,
    radial-gradient(var(--R) at left 50% top calc(-1*var(--p)),var(--_g)) 
     50%  calc(50% + var(--size)/2 + var(--b)/2) / calc(4 * var(--size)) calc(var(--size) + var(--b)) repeat-x;
}

After that step, the once-distant concept of a wavy line becomes precise and scalable.

Scaling to Patterns

By stripping away the repeat-x keyword from those earlier definitions, the result changes—instead of a single wavy separator, the gradients tile naturally into a repeating pattern across the element. That same mathematical constant used earlier for the line, calc(100% / 3), becomes the spacing system:

/* Size plus thickness */
calc(var(--size) + var(--b))

A similar restructuring of the gradient's coordinate direction turns the horizontal pattern into a vertical one with the same properties.

Simplifying the Code

Throughout these examples, size and curvature are stored as separate variables. But since the latter is a function of the former, the structure can be condensed into a single controlling variable:

.wave {
  --size: 50px;
  --m: 0.5;
  --R: calc(var(--size) * sqrt(var(--m) * var(--m) + 1));

  mask:
    radial-gradient(var(--R) at 50% calc(var(--size) * (1 + var(--m))), #000 99%, #0000 101%) 
      calc(50% - 2*var(--size)) 0/calc(4 * var(--size)) 100%,
    radial-gradient(var(--R) at 50% calc(-1 * var(--size) * var(--m)), #0000 99%, #000 101%) 
      50% var(--size) / calc(4 * var(--size)) 100% repeat-x;
  }

This removes the need for sqrt(). For one-off designs, even --m can be hardcoded, resulting in a very compact definition:

.wave {
  --size: 50px;
  --R: calc(var(--size) * 1.28);

  mask:
    radial-gradient(var(--R) at 50% calc(1.8 * var(--size)), #000 99%, #0000 101%) 
      calc(50% - 2*var(--size)) 0/calc(4 * var(--size)) 100%,
    radial-gradient(var(--R) at 50% calc(-.8 * var(--size)), #0000 99%, #000 101%) 
      50% var(--size) / calc(4 * var(--size)) 100% repeat-x;
}

The reduction from magic-number-solution to clear and concise logic means that adapting to your specific use case is a matter of tweaking one or two properties, not deep CSS surgery.

Limitations to Note

While mathematically correct, real-world behavior introduces two notable caveats. First, extreme values can produce flawed output, especially with very thick wavy lines relative to their size:

Rounding deviation is the second issue. Sometimes the geometry results in minor alignment gaps between wave segments:

However, for typical use, the method delivers smooth waves reliably. If an edge case produces distortion, simply adjust the curvature or thickness within the boundaries of the geometry to resolve the effect.