tool / svg wavesruns in your browser
SVG wave generator
Set the layers, the amplitude, the wavelength and the shape, shuffle the seed until it looks right, and copy one responsive SVG: as a file, a React component or a CSS background. Or keep just the line, and draw it on scroll.
- Layers
- 1–4
- Shapes
- 3
- Outputs
- 4
- Uploads
- 0
Make a wave
Each layer is one closed shape, its wave along the top and its flat side at the bottom, so it fills down to the next section: give the front layer that section’s colour. Switch the output to line to keep only the waves themselves, as strokes that scrollDraw draws.
Swatches are this site’s neutral colours in the theme you are viewing; the code writes their values.
viewBox 0 0 1440 200 · λ 576 · A 35 · …
The band under the wave is the next section, in the front layer’s colour. The SVG stretches to any width at a fixed height: try 390px.
…Points first, then a curve
The wave starts as a row of points: a crest, then a trough, every half wavelength, the amplitude above and below a baseline (fig. 1). The wavelength is the width divided by the number of waves. Layers get baselines spaced evenly down the height and phases spread along one wavelength, so their crests do not line up. Variation moves each point sideways by up to 15% of the spacing, pulls it toward the baseline by up to 75% of the amplitude and nudges each layer’s phase, all with numbers from the seed.
Then a curve through them. A Catmull-Rom spline passes through every point, its direction at each one parallel to the line between the two neighbours, and in SVG it is one cubic Bézier per gap: from P1 to P2, the handles sit at P1 + (P2 − P0) ÷ 6 and P2 − (P3 − P1) ÷ 6.
// One cubic Bézier per gap, through every point.
// p: the points, left to right; tension 0 is Catmull-Rom.
function catmullRom(p, tension = 0) {
const f = (1 - tension) / 6;
let d = `M${p[1].x},${p[1].y}`;
for (let i = 1; i < p.length - 2; i++) {
const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]];
d += `C${b.x + (c.x - a.x) * f},${b.y + (c.y - a.y) * f},` +
`${c.x - (e.x - b.x) * f},${c.y - (e.y - b.y) * f},` +
`${c.x},${c.y}`;
}
return d; // from p[1] to the point before last: the ends only steer
}Exactly 0 to the width. The points carry on past both edges, so every gap has neighbours whatever the phase. The curve is then cut where it crosses x = 0 and x = width (split with de Casteljau’s construction at the point where x is exactly the edge), and the shape closes down the side, back along the bottom and up: V height H 0 Z. At any seed, phase or number of waves the shape meets both sides of its box, with no sliver at either end.
Inside the box. A curve through points can swing past them. The generator finds each piece’s highest and lowest point, where the cubic’s slope is 0, and if one would leave the box it scales that layer toward its baseline just enough. With variation at 0 and a whole number of waves, the two ends meet at the same height and slope, so the wave tiles.
Use it as a section divider
Put the SVG between two sections and fill it with the colour of the one it leads into. With width="100%", a fixed height and preserveAspectRatio="none" it stretches to any width without changing height: wide screens get gentler waves, phones steeper ones (try the 390px preview). Flip it vertically to make it the bottom edge of the section above instead.
<section class="hero">…</section>
<!-- The wave, filled with the colour of the section it leads into. -->
<svg viewBox="0 0 1440 160" preserveAspectRatio="none"
width="100%" height="160" aria-hidden="true" style="display:block">
<path fill="#121110" d="M0,…V160H0Z"/>
</svg>
<section class="next" style="background:#121110">…</section>display: block removes them. If a hairline still shows where the wave meets the next section, because its edge lands between two pixels, overlap them by one: margin-bottom: -1px.As a CSS background the same SVG goes in a data URI, and it has to be URL-encoded. A # starts a URL fragment, so a hex colour left as it is cuts the image off there; %, <, > and spaces are not allowed as they are either. The CSS tab encodes them and swaps double quotes for single ones, so the SVG sits inside url("…"). background-size: 100% 100% stretches it the way preserveAspectRatio="none" stretches the inline SVG. This one is a single peak, short enough to read:
/* A 48px band, its flat side at the bottom: the next section
starts right below it, in the wave’s colour. */
.wave-divider {
height: 48px;
background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%201440%2048'%20preserveAspectRatio='none'%3E%3Cpath%20fill='%23121110'%20d='M0,4.8L720,43.2L1440,4.8V48H0Z'/%3E%3C/svg%3E");
background-size: 100% 100%;
background-repeat: no-repeat;
}Draw the wave line on scroll
In line mode the generator keeps only each layer’s wave, as an open path with a stroke and no fill. scrollDraw turns each stroke into one dash as long as the line and slides it into place as the page scrolls: by default from when the top of the wave reaches the bottom of the window until its bottom leaves the top (trigger 'top bottom' to 'bottom top'), at an even rate (linear easing). The draws and done controls change that, and the code includes only what differs from the defaults.
import { scrollDraw } from 'svg-scroll-draw';
// With the scroll: done as the wave leaves the top of the window.
scrollDraw('#wave');
// Done by the time it reaches the middle of the screen.
scrollDraw('#wave', { trigger: { end: 'center center' } });
// Once, on its own clock (1000 ms), the first time it is seen.
scrollDraw('#wave', { autoplay: true, once: true });
// Several lines, each starting a little after the one before.
scrollDraw('#wave', { stagger: 0.15 });preserveAspectRatio="none" on a phone, the flat stretches of the line come out thicker than the slopes. So the line keeps its proportions and scales evenly, and its stroke stays even. vector-effect="non-scaling-stroke" would even the width of a stretched one, but the browser then lays the dash out in screen pixels while scrollDraw sizes it from getTotalLength() in viewBox units, so the line would finish early.autoplay: true without once, a stagger or a callback could take the native CSS scroll-timeline path and follow the scroll instead of its own clock; fixed in 2.23.3. once: true, which the generator adds, keeps it on the clock in both.prefers-reduced-motion: reduce scrollDraw shows the line drawn from the start and never animates it.SVG wave questions
How do I make an SVG wave divider between two sections?
Put a wave SVG between the two sections and fill it with the colour of the section it leads into, so the wave looks like the top edge of that section. Give the <svg> a viewBox, width="100%", a fixed height and preserveAspectRatio="none", and display: block. For a wave hanging from the bottom of a section instead, flip it vertically. The generator above writes all of this.
How do I use an SVG wave as a CSS background?
Put the SVG in a data URI: background-image: url("data:image/svg+xml,…"), with background-size: 100% 100% and background-repeat: no-repeat. The SVG must be URL-encoded: # becomes %23 (left alone it starts a URL fragment and cuts the image off at the first hex colour), % becomes %25, < and > become %3C and %3E, and double quotes are swapped for single ones so the SVG fits inside url("…"). The CSS background tab does the encoding.
How do I make an SVG wave stretch to the full width?
Use a viewBox, width="100%" and a height in pixels, and set preserveAspectRatio="none": the drawing then fills the box exactly, stretched sideways but never taller, so it spans any screen at the same height. Without "none" the SVG keeps its proportions and either letterboxes or changes height with the width.
Why is there a gap under my SVG wave?
An inline <svg> sits on the text baseline, like a letter, and the space left for descenders shows under it. display: block on the svg removes it. A hairline can still show where the wave meets the next section when its edge lands between two pixels; overlapping by one pixel, margin-bottom: -1px, hides it.
How do I animate an SVG wave?
Draw its line: switch the generator to line, copy the SVG, and call scrollDraw('#wave') from svg-scroll-draw, which sizes the stroke as one dash and slides it into place as the page scrolls; { autoplay: true, once: true } draws it once on its own clock when it is first seen. Under prefers-reduced-motion it shows the line drawn. To make a filled wave drift sideways instead, it has to tile: set variation to 0 and a whole number of waves, so both ends meet at the same height and slope, then repeat it with background-repeat: repeat-x and a background-size in pixels, and animate background-position-x by one tile width, inside @media (prefers-reduced-motion: no-preference).
What is a Catmull-Rom curve, and what does tension do?
A Catmull-Rom spline is a smooth curve that passes through every point, its direction at each point parallel to the line between the two neighbours. Each gap becomes one cubic Bézier, C in SVG path data, with handles at P1 + (P2 − P0) ÷ 6 and P2 − (P3 − P1) ÷ 6. Tension multiplies both handles by 1 − tension: 0 is the classic curve, 1 leaves no handles and straight lines.
Will the same seed give the same wave?
Yes. Every random number comes from a small seeded generator (mulberry32), one stream per layer, so the same seed and settings give the same path in any browser. Shuffle picks a new seed. With variation at 0 the seed has no effect.
Drawing on scroll with svg-scroll-draw 2.23.3: the docs, or the SVG path viewer to measure and check a line before it draws.