I Built a Visual SVG Wave Generator in Pure Vanilla JS — 5 Wave Types, Download SVG/PNG, 162 Tests
Wave dividers are one of those design details that look effortless in a finished layout but are surprisingly fiddly to produce by hand. Getting the Bézier control points right for a smooth sine wave, stacking multiple layers with the right opacity offsets, tuning amplitude and period until it "feels" right — it's the kind of thing you want a tool for.
So I built one. Zero dependencies, single HTML file, runs entirely in your browser.
Live tool → svg-wave-generator.pages.dev
All tools → devnestio.pages.dev
What the tool does
- 5 wave types: Sine, Layered, Shark Fin, Step, and Bumps — each with its own character
- Shape controls: Amplitude (5–150 px), Period (50–600 px), Height (40–300 px), and Layers (1–3, for the Layered type)
- Color: Gradient (top/bottom two-color linear gradient) or Solid — both with an opacity slider
- Flip transforms: Flip Horizontal and Flip Vertical independently
- Background color picker: preview your wave against any page background
-
Three outputs:
- SVG code — paste directly into HTML or Figma
-
CSS background —
background: url("data:image/svg+xml,...")ready to drop into a stylesheet -
SVG download (1200 px wide) + PNG download (via Canvas
toDataURL)
- Real-time preview that updates on every slider change
No build step. No npm. Open the file in a browser and it works.
The five wave shapes
Sine
The classic smooth wave. Built with cubic Bézier curves — each half-period gets two control points that approximate a sinusoidal curve.
function generateSinePath(w, h, amplitude, period, flipH, flipV) {
const baseline = flipV ? amplitude : h - amplitude;
const amp = flipV ? -amplitude : amplitude;
const cp = period / 2;
let d = `M0,${h} L0,${baseline}`;
let x = 0;
while (x < w + period * 2) {
d += ` C${x + cp/2},${baseline - amp} ${x + cp},${baseline - amp} ${x + cp},${baseline}`;
d += ` C${x + cp},${baseline + amp} ${x + cp*1.5},${baseline + amp} ${x + period},${baseline}`;
x += period;
if (x > w + period * 2) break;
}
d += ` L${w},${h} Z`;
return d;
}
Layered
Two or three sine-like waves stacked on top of each other, with each layer progressively smaller in amplitude and shifted in phase. The opacity decreases per layer to create depth.
function generateLayeredPaths(w, h, amplitude, period, layers, flipH, flipV) {
const paths = [];
for (let i = 0; i < layers; i++) {
const layerAmp = amplitude * (1 - i * 0.25);
const layerOffset = (h * 0.12) * i;
// ... build each path with a phase shift of (period * i * 0.3)
paths.push(d);
}
return paths; // rendered with opacity 1.0, 0.8, 0.6
}
Shark Fin
Asymmetric peaks — quick Bézier rise to a sharp tip, then a steep drop, then a flat segment. Useful for aggressive, angular dividers.
Step
Staircase-style edges using pre-defined level ratios. A lookup table of 10 height levels [0, 0.4, 0.7, 1.0, 0.6, ...] is cycled across the width, producing an irregular staircase that looks hand-crafted.
Bumps
Uniform semicircular bumps along the baseline. Unlike the sine wave, bumps are one-sided — they only protrude upward (or downward with flipV). Each bump is a cubic Bézier that rises and returns to the baseline.
Architecture: pure functions and module.exports in the browser
The key design decision was making every path-generation function a pure function — takes numbers, returns a string. No DOM, no state, no side effects. This makes them trivially testable in Node.js.
The trick for sharing code between the browser and Node is a single guard at the bottom of the <script>:
if (typeof module !== 'undefined') {
module.exports = {
generateSinePath,
generateLayeredPaths,
generateSharkFinPath,
generateStepPath,
generateBumpsPath,
generateGradientDef,
buildSVGString,
svgToCSSDataURI,
parseHexColor,
clamp,
lerpColor
};
}
In the browser, module is undefined so this block is skipped. In Node.js, the functions get exported and the test suite can require() them.
Testing: 162 tests with zero test frameworks
Loading a browser HTML file in Node.js needs a little care because the global environment is different. I use Node's built-in vm module to run the extracted <script> block in a sandboxed context with mocked browser globals:
const vm = require('vm');
const modObj = { exports: {} };
const ctx = vm.createContext({
module: modObj,
exports: modObj.exports,
window: { addEventListener: () => {}, _resizeTimer: null },
document: {
getElementById: () => ({ style: {}, value: '' }),
querySelectorAll: () => [],
addEventListener: () => {}
},
navigator: {},
console,
setTimeout: () => 0,
DOMParser: class { parseFromString() { return { documentElement: {} }; } },
// ...
});
vm.runInContext(scriptContent, ctx);
const { generateSinePath, buildSVGString, ... } = modObj.exports;
Then tests are plain assert calls wrapped in a test() helper:
function test(name, fn) {
try { fn(); passed++; }
catch(e) { failed++; errors.push({ name, message: e.message }); }
}
test('sine path ends with Z', () =>
assert.ok(generateSinePath(800, 120, 40, 200, false, false).endsWith('Z'))
);
test('buildSVGString has linearGradient', () =>
assert.ok(buildSVGString(baseCfg).includes('linearGradient'))
);
162 tests covering:
| Area | Tests |
|---|---|
parseHexColor |
12 — shorthand, full, uppercase, specific values |
clamp |
10 — edges, floats, negative ranges |
lerpColor |
10 — t=0, t=1, midpoints, valid hex output |
generateGradientDef |
10 — gradient vs solid, offsets, color preservation |
generateSinePath |
18 — path structure, flip variants, parameter sensitivity |
generateLayeredPaths |
14 — layer counts, array structure, flip, phase shift |
generateSharkFinPath |
12 — structure, period range, flip |
generateStepPath |
12 — structure, period range, flip, wide width |
generateBumpsPath |
13 — structure, period range, both flips |
buildSVGString |
25 — all 5 types, xmlns, viewBox, gradient, opacity, colors |
svgToCSSDataURI |
12 — encoding, url() wrap, no raw </>
|
| Integration | 14 — edge cases, cross-function consistency |
All 162 pass with node test/test.js. No Mocha, no Jest, no Vitest.
SVG-to-CSS data URI encoding
The CSS background output encodes the SVG as a data URI. The encoding needs to be careful — some characters must be percent-encoded for the CSS url() value to parse correctly:
function svgToCSSDataURI(svg) {
const encoded = svg
.replace(/"/g, "'") // double → single quotes (avoids breaking url("..."))
.replace(/\n\s*/g, ' ') // collapse whitespace
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
.replace(/#/g, '%23'); // # breaks URL fragment parsing
return `url("data:image/svg+xml,${encoded}")`;
}
The resulting CSS is something like:
.wave-divider {
width: 100%;
height: 120px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' ...%3E") center/cover no-repeat;
}
No external image file, no server request, works offline.
PNG export via Canvas
SVG download is straightforward (Blob → URL.createObjectURL). PNG export goes through Canvas:
document.getElementById('btn-dl-png').addEventListener('click', () => {
const svgStr = buildSVGString({ ...state, width: 1200 });
const blob = new Blob([svgStr], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 1200;
canvas.height = state.height;
canvas.getContext('2d').drawImage(img, 0, 0, 1200, state.height);
URL.revokeObjectURL(url);
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = `wave-${state.type}.png`;
a.click();
};
img.src = url;
});
This works reliably in all modern browsers — the browser renders the SVG into an <img> element, then Canvas captures the pixel data.
What I'd add next
-
Animation export — CSS
@keyframesthat shifts the wave horizontally for a flowing effect - Multiple wave stacking — overlay two different wave types
- Preset gallery — save and share named wave configurations via URL hash
- Width/aspect ratio control — currently the download is always 1200 px wide
Try it
svg-wave-generator.pages.dev — free, no login, no tracking.
Part of devnestio.pages.dev — a growing collection of browser-only developer and designer tools.
Top comments (0)