DEV Community

Dev Nestio
Dev Nestio

Posted on

I Built a Visual SVG Wave Generator in Pure Vanilla JS — 5 Wave Types, Download SVG/PNG, 162 Tests

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 backgroundbackground: 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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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'))
);
Enter fullscreen mode Exit fullscreen mode

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}")`;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

No external image file, no server request, works offline.


PNG export via Canvas

SVG download is straightforward (BlobURL.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;
});
Enter fullscreen mode Exit fullscreen mode

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 @keyframes that 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)