Font size: Default, 100%
Dark mode

Dynamowaves

A dependency-free HTML custom element for responsive SVG waves that generate and morph on demand.

Version 2.2.0 · 12.0 KB minified · 3.6 KB gzip.

Regen Header

Wave controls ready.

Installation

Dynamowaves is a dependency-free custom element. It keeps the authored <dynamo-wave> host in the document and renders a generated SVG path inside it.

npm

npm install dynamowaves

A side-effect import is enough when you only need the element:

import 'dynamowaves';

Then author the element in ordinary HTML and give its host a visible height and fill:

<dynamo-wave class="hero-wave" data-wave-points="8"></dynamo-wave>

<style>
  .hero-wave {
    display: block;
    width: 100%;
    height: 5rem;
    fill: rebeccapurple;
  }
</style>

The package also provides named ESM exports:

import { DynamoWave, generateWave, encodeWaveSeed } from 'dynamowaves';

CommonJS receives the same runtime API:

const { DynamoWave, generateWave, encodeWaveSeed } = require('dynamowaves');

Importing the package is safe during SSR: it does not require browser globals to evaluate. The custom element registers only in a browser environment with customElements; its SVG is created after client-side upgrade.

Bundlers and frameworks

Import dynamowaves once from the browser entry point used by your application, then use <dynamo-wave> directly in HTML, JSX, or a framework template. Attributes remain strings in markup; use an element reference for generateNewWave(), play(), and pause().

If framework code may run before the custom element has upgraded, wait for its definition before calling methods:

import 'dynamowaves';
import type { DynamoWave } from 'dynamowaves';

await customElements.whenDefined('dynamo-wave');

const wave = document.querySelector<DynamoWave>('dynamo-wave');
wave?.generateNewWave(500);

Server-rendered frameworks may emit the <dynamo-wave> host normally. Importing the module during SSR is safe, but method calls and DOM queries still belong in the framework’s client lifecycle. For an identical server and first-client shape, render a previously recorded data-wave-seed; otherwise the SVG is generated when the element upgrades.

Direct script or CDN

The UMD files remain available for projects that load scripts directly. They register <dynamo-wave> and expose the helper API as globalThis.Dynamowaves.

<!-- Local copy -->
<script src="/path/to/dynamowaves.min.js"></script>

<!-- Latest compatible 2.x release from npm through jsDelivr -->
<script
  src="https://cdn.jsdelivr.net/npm/dynamowaves@2/dist/dynamowaves.min.js"
  crossorigin="anonymous"
></script>

<script>
  const path = Dynamowaves.generateWave({
    width: 1440,
    height: 160,
    points: 6,
    variance: 3,
  });
</script>

Replace @2 with an exact published version when a deployment must not move to a newer compatible release automatically.

Angular

After installing from npm, add the browser bundle to the scripts array in angular.json:

"scripts": [
  "node_modules/dynamowaves/dist/dynamowaves.js"
]

Then enable custom-element markup in the owning module:

import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';

@NgModule({
  // ...
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}

Usage

Add the custom element anywhere a responsive SVG wave belongs:

<dynamo-wave
  style="display:block;height:5rem;fill:rebeccapurple"
></dynamo-wave>

Without configuration, the wave faces top, uses six points and variance 3, and renders one static shape. Add data-wave-animate="true" for continuous motion or call the public methods when motion should respond to an interaction.

Shape and direction

Use data-wave-face to choose the edge the filled portion occupies, data-wave-points to change the number of anchors, and data-wave-variance to control their deviation. data-start-end-zero is useful when the wave must join a straight edge cleanly.

<dynamo-wave
  data-wave-face="bottom"
  data-wave-points="8"
  data-wave-variance="2.5"
  data-start-end-zero
  style="display:block;height:5rem;fill:slateblue"
></dynamo-wave>

The complete accepted values, defaults, and live-update behavior are in the attribute reference.

Basic styling

The <dynamo-wave> host remains in the document, so its class, id, and style stay available for layout and styling. Set a height on the host and use fill to color the generated path.

<!-- Example 1 -->
<dynamo-wave style="fill:slateblue"></dynamo-wave>

<!-- Example 2 -->
<style>
  .fill-theme {
    fill: var(--zbk-brand-canvas);
  }
</style>
<dynamo-wave class="fill-theme"></dynamo-wave>

<!-- Example 3 -->
<style>
  #special_wave {
    height: 3rem;
    width: 80%;
    transform: translateX(10%);
  }
</style>
<dynamo-wave id="special_wave" class="fill-theme fill-light"></dynamo-wave>

The SVG stretches to the host with preserveAspectRatio="none". That makes the wave responsive, but it also means the host’s dimensions are the layout contract. Style the host rather than the generated svg or path children.

Updating a connected wave

All nine public attributes are observed. Changing one after upgrade updates the element without replacing it:

await customElements.whenDefined('dynamo-wave');

const wave = document.querySelector('dynamo-wave');
wave.dataset.wavePoints = '10';
wave.dataset.waveFace = 'left';
wave.dataset.waveAnimate = 'true';

Geometry changes rebuild the wave. Speed, automatic animation, and viewport observation are reconfigured in place; an active loop resumes when a geometry rebuild finishes.

Playing with motion

Use generateNewWave() for a one-off morph, or play() and pause() for a continuous loop. The interactive examples below use the same public methods described in the JavaScript API reference.

New Wave
Play Wave Pause Wave

Wave controls ready.

API reference

Version 2.2.0 exposes nine observed attributes, three instance methods, one completion event, and six runtime module exports. Attribute changes take effect after the element is connected: geometry changes rebuild the wave, while speed, animation, and observation changes update their active behavior.

Attributes

AttributeDefaultAccepted valuesRuntime behavior
data-wave-facetoptop, bottom, left, or right. Any other value behaves like top.Rebuilds the SVG with horizontal or vertical geometry and the requested orientation.
data-wave-points6A numeric string. It is parsed as an integer and clamped to a minimum of 2; invalid values use the default.Regenerates the current and target paths with the new point count.
data-wave-variance3Any finite number, including decimals. Positive values are the useful range for ordinary wave depth.Regenerates the wave with the new amount of anchor deviation.
data-varianceUnsetLegacy alias for data-wave-variance. The preferred attribute wins when both are present.Regenerates the wave. Retained for existing markup.
data-wave-speed7500A finite number greater than zero, in milliseconds. Invalid values use the default.Updates a stopped wave immediately. A running loop restarts from the displayed shape at the new speed.
data-wave-animatefalseThe exact string true starts the loop; any other value stops it.Starts or pauses continuous morphing, subject to the viewer's reduced-motion preference.
data-wave-observeUnsetonce or repeat, optionally followed by an IntersectionObserver root margin such as repeat:100px.Replaces the active observer. Regeneration occurs when the wave is outside the adjusted viewport.
data-wave-seedGeneratedAn encoded path snapshot produced by Dynamowaves, or any nonempty string for deterministic generation.Rebuilds the wave from the supplied seed. Every render reflects its resulting path back to this attribute.
data-start-end-zeroFalseAn empty value, true, 1, yes, or on enables it. Any other value disables it.Regenerates the wave with both visible endpoints anchored to the base edge.

Geometry changes discard an existing encoded path snapshot because it no longer describes the requested shape. If the wave was looping, it resumes after rebuilding.

<dynamo-wave
  data-wave-face="bottom"
  data-wave-points="8"
  data-wave-variance="2.5"
  data-start-end-zero
></dynamo-wave>

Recorded and deterministic seeds

Dynamowaves supports two seed forms:

  • Recorded path: after every render, the element writes an unpadded Base64 representation of its current SVG path to data-wave-seed. Copy that value when another wave must use the exact same shape. This is ordinary Base64, not a URL-safe encoding.
  • Deterministic string: a nonempty value that is not an encoded Dynamowaves path seeds the internal random-number generator. After rendering, the string is replaced by the recorded path it produced.
<dynamo-wave id="hero-wave" data-wave-seed="homepage-hero-v1"></dynamo-wave>

<script>
  const hero = document.getElementById('hero-wave');
  const footer = document.createElement('dynamo-wave');

  // Read after the hero has connected so this is its recorded path.
  footer.dataset.waveSeed = hero.dataset.waveSeed;
  document.body.appendChild(footer);
</script>
Original deterministic seed
Copied recorded path

If you later change a geometry attribute, reapply the original deterministic string if you want that new geometry to remain tied to the same human-readable seed.

Viewport observation

data-wave-observe="once" regenerates on the first non-intersecting observation and then disconnects. repeat stays connected and regenerates whenever the observer reports the element outside its root.

<!-- Regenerate once when outside the viewport. -->
<dynamo-wave data-wave-observe="once"></dynamo-wave>

<!-- The positive margin expands the observed viewport, so the wave must move
     farther away before it becomes non-intersecting. -->
<dynamo-wave data-wave-observe="repeat:100px"></dynamo-wave>

<!-- A negative margin contracts it, so non-intersection happens sooner. -->
<dynamo-wave data-wave-observe="once:-50px"></dynamo-wave>

The margin uses the same syntax as IntersectionObserver.rootMargin, including multi-value forms such as 100px 0px. If IntersectionObserver is unavailable, Dynamowaves warns once for that setup and leaves viewport regeneration disabled; the wave itself still renders.

JavaScript API

MethodBehaviorWhile another animation is active
generateNewWave(duration = 800)Morphs once from the displayed path to a new path. Durations below one millisecond, and reduced motion, resolve in one millisecond.Ignored while another morph or animation frame is active.
play(duration?)Starts continuous morphing. A finite positive duration overrides data-wave-speed for that loop.Ignored while already playing, while a one-off morph is active, or while reduced motion is enabled.
pause()Stops a loop and preserves its current tween position for a later play().Cancels an active one-off morph and clears that morph's timeline.

All three methods return void. Use the completion event rather than internal state such as isAnimating when sequencing work.

const wave = document.querySelector('dynamo-wave');

wave.addEventListener('dynamo-wave-complete', () => {
  console.log('The requested morph finished.');
}, { once: true });

wave.generateNewWave(500);

dynamo-wave-complete

The element dispatches dynamo-wave-complete after a one-off morph and after every completed cycle of play(). It is a non-bubbling, non-composed CustomEvent.

wave.addEventListener('dynamo-wave-complete', (event) => {
  event.detail.duration;  // number, milliseconds
  event.detail.direction; // 'horizontal' | 'vertical'
});

The event reports orientation rather than the authored face: top and bottom are horizontal; left and right are vertical.

Module and helper exports

Importing the package registers <dynamo-wave> when a custom-element registry is available. The same entry point also exposes the component class and its lower-level path helpers.

import {
  DynamoWave,
  generateWave,
  parsePath,
  interpolateWave,
  encodeWaveSeed,
  decodeWaveSeed,
} from 'dynamowaves';
ExportPurpose
DynamoWaveThe custom-element class registered as dynamo-wave in browser environments.
generateWave(options)Creates a complete SVG path string from width, height, point, variance, orientation, random-source, and endpoint options.
parsePath(path)Extracts the quadratic control points and endpoints used by the interpolator.
interpolateWave(current, target, progress, vertical, height, width)Builds the path for one interpolation progress value between compatible point arrays. Orientation and dimensions are required so the returned path closes against the correct base edge.
encodeWaveSeed(path)Normalizes a path and returns its unpadded Base64 snapshot, or an empty string when it cannot encode.
decodeWaveSeed(seed)Returns a validated Dynamowaves path or null. Plain deterministic seed strings intentionally return null.

These helpers are available from both ESM and CommonJS. A direct browser script exposes the same names on globalThis.Dynamowaves.

generateWave options

generateWave() is the low-level geometry API. It does not create or modify DOM; it returns a closed SVG path string.

OptionRequiredPurpose
widthYesNumeric width used by the generated coordinate system.
heightYesNumeric height used by the generated coordinate system.
pointsYesAnchor count. Finite values are floored and clamped to at least 2.
varianceYesMultiplier controlling how far randomized anchors can deviate across the wave depth.
verticalNofalse for top/bottom geometry; true for left/right geometry.
randomNoA function returning a number. Defaults to Math.random; inject a seeded or fixed source for repeatable output.
startEndZeroNoAnchors both visible endpoints to the base edge when true.
import { generateWave } from 'dynamowaves';

// A fixed sequence keeps the example repeatable without flattening the wave.
const samples = [0.25, 0.85, 0.15, 0.7, 0.35, 0.9, 0.2, 0.6];
let sample = 0;

const path = generateWave({
  width: 1440,
  height: 160,
  points: 8,
  variance: 2.5,
  random: () => samples[sample++ % samples.length],
  startEndZero: true,
});

document.querySelector('#generated-wave path').setAttribute('d', path);
Wave generated from eight varied points with anchored endpoints

Parsing and interpolating paths

parsePath() understands the quadratic path format emitted by Dynamowaves. interpolateWave() expects two parsed arrays with the same number of segments, a progress value from 0 to 1, the orientation, and the same height and width used to generate the paths.

import { generateWave, interpolateWave, parsePath } from 'dynamowaves';

const options = { width: 1440, height: 160, points: 6, variance: 3 };
const sequence = (samples) => {
  let sample = 0;
  return () => samples[sample++ % samples.length];
};

const startPath = generateWave({
  ...options,
  random: sequence([0.15, 0.8, 0.35, 0.95, 0.25, 0.65]),
});
const endPath = generateWave({
  ...options,
  random: sequence([0.75, 0.45, 0.9, 0.2, 0.6, 0.1]),
});

const halfwayPath = interpolateWave(
  parsePath(startPath),
  parsePath(endPath),
  0.5,
  false,
  options.height,
  options.width,
);
Start Starting wave path
Halfway Wave path halfway between start and target
Target Target wave path

Malformed or unrelated path strings produce an empty array from parsePath(). The helpers do not repair mismatched point arrays; validate compatibility before calling interpolateWave().

Encoding and decoding snapshots

import { decodeWaveSeed, encodeWaveSeed } from 'dynamowaves';

const seed = encodeWaveSeed(path);
const restoredPath = decodeWaveSeed(seed); // string
const plainSeed = decodeWaveSeed('homepage-hero-v1'); // null

decodeWaveSeed() only accepts encoded strings that decode to the Dynamowaves path format. Returning null for a plain deterministic seed is expected; the custom element, rather than this helper, turns that string into a seeded random source.

TypeScript exports

The package declarations export DynamoWave, DynamoWaveAttributes, DynamoWaveCompleteDetail, DynamoWaveEventMap, WaveDirection, WaveGenerationOptions, WaveObserverOptions, WaveOrientation, and WavePoint. They also add <dynamo-wave> to HTMLElementTagNameMap, type the completion event on HTMLElementEventMap, and provide the custom element’s JSX attributes.

Styling and layout

The custom-element host stays in the document. Its id, classes, inline styles, and data attributes remain on that host; Dynamowaves renders a light-DOM SVG inside it.

<dynamo-wave class="section-wave"></dynamo-wave>

<style>
  .section-wave {
    display: block;
    width: 100%;
    height: 5rem;
    fill: rebeccapurple;
    stroke: transparent;
  }
</style>

The generated SVG fills the host with width: 100%, height: 100%, and preserveAspectRatio="none". Its path inherits fill and stroke; text color alone does not set the wave fill. When an unstyled host computes to display: inline, Dynamowaves gives it an inline display: block default. An authored display value wins.

Style the host rather than depending on the generated svg and path structure as a selector contract.

Lifecycle, SSR, and accessibility

  • The module can be imported during SSR or in Node without HTMLElement or customElements. Registration happens only where a browser custom-element registry exists.
  • Server output contains the authored <dynamo-wave> host; the SVG is created when the element upgrades in the browser. Reuse a recorded data-wave-seed when the first client-rendered shape must be identical across pages or environments.
  • The generated SVG is decorative: it uses aria-hidden="true" and role="presentation". Do not use the wave as the only carrier of meaningful information.
  • prefers-reduced-motion: reduce prevents continuous playback and reduces one-off morphs to one millisecond. A running authored loop pauses when the preference changes live and resumes when motion is allowed again, unless animation was explicitly disabled in the meantime.
  • Removing an active element cancels its animation frame and disconnects its observers. Reattaching it resumes a loop that had been running; an interrupted one-off morph does not resume.
  • Changing geometry while a loop is active rebuilds the paths and resumes the loop with the new configuration.
  • The baseline browser requirements are Custom Elements and requestAnimationFrame. IntersectionObserver is only required for data-wave-observe; the wave still renders when observation is unavailable.

Troubleshooting

  • The element takes up no useful space: give the <dynamo-wave> host an explicit or layout-derived height. Its generated SVG is height: 100% and cannot invent the surrounding layout.
  • The wave is present but invisible: set fill on the host and check that it is not the same color as the surface behind it. color alone does not set the path fill.
  • A method is undefined: make sure the package was imported, then wait for the custom element definition before calling instance methods. The framework example above shows the exact customElements.whenDefined() pattern.
  • A parent listener never sees completion: dynamo-wave-complete does not bubble or cross a shadow boundary. Attach the listener directly to the <dynamo-wave> element.
  • A readable seed string changes after render: that is expected. The element replaces it with the encoded snapshot it generated. Save the original string separately if you need to reapply it after changing geometry.
  • Animation does not start: data-wave-animate only accepts the exact string true, and continuous animation is intentionally disabled while the viewer requests reduced motion.
  • Viewport regeneration feels early or late: positive data-wave-observe margins expand the observed viewport and delay non-intersection; negative margins contract it and make non-intersection happen sooner.

Practical examples

Dynamowaves supplies geometry and motion while your layout supplies the size, color, and placement. These recipes use only the public host attributes, methods, and completion event.

Sticky header divider

Keep a labeled header pinned inside a scrolling panel and let a bottom-facing wave soften its edge:

<div class="scrolling-panel">
  <div class="panel-header">
    <h2>Wave settings</h2>
    <dynamo-wave
      data-wave-face="bottom"
      style="display:block;height:3rem;fill:var(--header-background)"
    ></dynamo-wave>
  </div>
  <div class="panel-content">...</div>
</div>

<style>
  .scrolling-panel {
    --header-background: #dbeafe;
    max-height: 24rem;
    overflow: auto;
  }

  .panel-header {
    position: sticky;
    top: 0;
    background: var(--header-background);
  }
</style>

I'm the heading!

A top or bottom wave uses horizontal geometry. Use data-wave-face="bottom" when the filled portion should remain attached to the header above it.

The wave stretches to its host. Set that host's height deliberately rather than selecting the generated SVG.

Fill inherits from the custom element, so the same surface token can color both the header and its divider.

Add data-start-end-zero when the wave must meet a straight adjoining edge at both ends.

The content keeps scrolling while the header and its generated divider stay pinned.

Event-driven transition

Pair generateNewWave() with the completion event when interface state should settle after a morph. Listen on the wave itself because the event does not bubble.

<dynamo-wave
  id="transition-wave"
  style="display:block;height:4rem;fill:rebeccapurple"
></dynamo-wave>
<button id="next" type="button">Next</button>
const wave = document.querySelector('#transition-wave');
const nextButton = document.querySelector('#next');

nextButton.addEventListener('click', () => {
  nextButton.disabled = true;

  wave.addEventListener('dynamo-wave-complete', () => {
    nextButton.disabled = false;
    nextButton.focus();
  }, { once: true });

  wave.generateNewWave(500);
});
Content 1

Content 1 of 4 is visible.

Responsive image-edge divider

Place a vertical wave over the edge of an image or visual panel. A small negative overlap prevents a one-pixel seam while the layout resizes.

<div class="feature-card">
  <div class="feature-image">
    <img src="/path/to/image.jpeg" alt="A school of fish" />
    <dynamo-wave
      data-wave-face="left"
      style="fill:var(--card-background)"
    ></dynamo-wave>
  </div>
  <div class="content">...</div>
</div>

<style>
  .feature-card {
    --card-background: white;
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(12rem, 1fr);
    background: var(--card-background);
  }

  .feature-image {
    position: relative;
    min-height: 16rem;
  }

  .feature-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .feature-image dynamo-wave {
    position: absolute;
    inset: -1px -1px -1px auto;
    width: 3rem;
  }
</style>
A school of orange fish swimming through blue water

Eye-catching headline.

Further information to draw interest.

Explore Dynamowaves

An idea by Mark Zebley