Documentation

API reference

One class, a handful of options, organic motion on any element. Everything PerlinDOM does, and how to drive it.

Installation

Install from npm. PerlinDOM has no dependencies.

npm install @andresclua/perlindom

Or import the ES module straight from a CDN:

<script type="module">
  import PerlinDOM from 'https://unpkg.com/@andresclua/perlindom/dist/perlindom.es.js';
</script>

Quick start

Pick an element and give it a range per axis. It starts moving right away, from wherever your CSS puts it.

import PerlinDOM from '@andresclua/perlindom';

const drift = new PerlinDOM({
  element: document.querySelector('.card'),
  x: { min: -50, max: 50 }, // ±50px horizontally
  y: { min: -30, max: 30 }, // ±30px vertically
  speed: 0.01,
  seed: 123, // same seed, same path, every time
});

drift.pause();   // eases to a stop
drift.play();    // eases back in
drift.destroy(); // stops and restores the original inline transform

PerlinDOM writes transform: translate3d(…) and keeps any scale, rotate or skew your CSS already sets. If the element is position: static it sets position: relative on it for you.

Options

Everything is optional. new PerlinDOM({ ... }) takes one object:

Constructor options
OptionTypeDefaultDescription
elementHTMLElement | nullnullThe element to move. With null nothing is moved: the instance only computes lastX / lastY for you to use.
x{ min, max } | nullnullHorizontal range, in px (numbers) or % of the parent (strings like '10%'). null keeps the axis still.
y{ min, max } | nullnullVertical range, same rules as x.
speednumber0.01How far through the noise each frame travels. Lower is slower; 0.002 to 0.03 covers most uses.
seednumber0Picks the path: it seeds the noise and is also the starting point in time. Same seed, same path.
lerpSpeednumber0.1How quickly pause() and play() ease out and in, per frame. Keep it between 0 and 1; 0 never finishes easing.
preserveBaseTranslationbooleanfalseBy default the translate part of the element's CSS transform is dropped (PerlinDOM's translate replaces it) and scale, rotate and skew are kept. true keeps the CSS translate too.
amplitudenumber1Strength of the drift: 1 is free, 0 pins the element to target. Clamped to 0 to 1; anything that is not a number (including NaN, numeric strings and null) becomes 1.
target{ x, y } | nullnullThe point amplitude pulls toward, in px, as an offset from the element's base position. A missing or non-finite coordinate becomes 0; null means the base position itself.
pauseOffscreenbooleantrueSkip frames while the element's parent is outside the viewport. false keeps the instance running wherever it is.

Value types

Pixels

Numbers are px. The element moves the same distance at any screen size.

x: { min: -50, max: 50 }

Percentages

Strings ending in % are a share of the parent's clientWidth (for x) or clientHeight (for y), padding included. They are re-read every frame, so a resized container applies on the next frame with no resize code. They need an element: with element: null there is no parent to measure.

x: { min: '-10%', max: '10%' }

Mixed

Each axis, and each end of a range, can use its own unit.

x: { min: '-5%', max: '5%' },
y: { min: -20, max: 20 },

Single axis

Leave an axis out, or set it to null, and it does not drift.

new PerlinDOM({ element, x: { min: -100, max: 100 }, y: null });

Methods

All methods return undefined.

pause()
Eases the motion to a stop at lerpSpeed. Once fully stopped the instance leaves the animation loop and the element stays where it is.
play()
Resumes after pause(), easing back in. A new instance is already playing, so you do not need to call it to start.
destroy()
Stops for good: leaves the loop, stops watching visibility, removes the resize listener and restores the element's original inline transform.
setTarget(target | null)
Moves the point that amplitude pulls toward. Takes { x, y } in px from the base position; non-finite coordinates become 0. null (or anything that is not an object) clears it.
refreshBaseTransform(preserveTranslation?)
Re-reads the element's CSS transform. The base transform is captured once, at construction, so call this after a class change alters scale, rotate or skew. The argument defaults to the preserveBaseTranslation option.
init(seed?)
Rebuilds the noise from seed and restarts playback (also after destroy()). It does not reset the current time. Without an argument the noise uses seed 0, not the seed you constructed with.

Properties

Instance properties
PropertyTypeDescription
amplitudenumberRead and write. Same clamping as the option: 0 to 1, non-numbers become 1. Safe to tween every frame.
target{ x, y } | nullThe current target. Read it; change it with setTarget(), which sanitizes the values.
speednumberRead and write, takes effect on the next frame.
lerpSpeednumberRead and write, used by the next pause() / play() ease.
xRange, yRange{ min, max } | nullThe x / y options, writable. Whether percentages are resolved is decided at construction, so switch between px values only, or build a new instance.
lastX, lastYnumberThe offset in px applied on the last frame, after amplitude. They hold still while paused or offscreen.
isPlayingbooleanfalse from pause() on, even while it is still easing out; true after play().

Amplitude and target

Each frame the noise gives an offset inside your ranges. amplitude blends that offset toward target:

offset = target + (noise - target) * amplitude
  • 1 is the plain noise, exactly as without these options.
  • 0 pins the element to its target, or to its base position when there is no target.
  • In between, it breathes around the target. The target applies on an axis with no range too, where the noise is 0.

Give many elements targets that draw a shape, then lower amplitude: the drift settles into the shape. Try it:

Twelve dots, one ring

Each dot's target sits on a ring. Slide amplitude to 0.

1.00

Shared loop and offscreen pause

All instances run on one shared requestAnimationFrame loop, which stops when nothing is playing, so hundreds of elements cost one callback per frame. One shared IntersectionObserver watches each element's parent (not the element: its own drift could carry it out of view and freeze it there). While the parent is offscreen the instance skips its frames entirely (time does not advance, the transform is not written) and picks up where it left off when it scrolls back in. Changing amplitude or calling setTarget() still renders one frame while offscreen, so a scrubbed effect always lands. pauseOffscreen: false turns the pause off. Without IntersectionObserver, instances simply never pause. An instance with element: null has nothing to observe and always runs.

GSAP + ScrollTrigger

PerlinDOM owns the element's transform, so do not tween x, y or transform on the same element. Tween amplitude instead. This is how the home page gathers its circles into the title: one proxy value, scrubbed by the scroll, copied to every instance.

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import PerlinDOM from '@andresclua/perlindom';

gsap.registerPlugin(ScrollTrigger);

const shape = [{ x: 0, y: -80 }, { x: 70, y: 40 }, { x: -70, y: 40 }]; // one { x, y } per .dot

const instances = [...document.querySelectorAll('.dot')].map((el, i) =>
  new PerlinDOM({
    element: el,
    x: { min: -120, max: 120 },
    y: { min: -120, max: 120 },
    seed: i * 7919,
    target: shape[i], // { x, y } offsets that draw your shape
  })
);

const state = { amplitude: 1 };
gsap.to(state, {
  amplitude: 0,
  ease: 'none',
  scrollTrigger: { trigger: '.scene', start: 'top top', end: '+=1000', pin: true, scrub: 0.8 },
  onUpdate: () => instances.forEach(inst => { inst.amplitude = state.amplitude; }),
});

amplitude is a real getter and setter, so gsap.to(instances, { amplitude: 0 }) works as well; the proxy keeps it to one tween for any number of elements. To move an element with GSAP and drift it too, put PerlinDOM on a wrapper or a child.

Best practices

  • One instance per element. Two instances on the same element overwrite each other's transform and visibility tracking.
  • Position with CSS, not with translate. Use left/top, margins, grid or flex. A CSS translate is dropped unless you pass preserveBaseTranslation: true.
  • Use percentages for responsive motion and give the parent a real size.
  • Pick calm speeds. Start at 0.01; above about 0.05 motion turns jittery.
  • Destroy what you remove. Call destroy() before taking an element out of the page, or the instance keeps a reference to it.
  • Respect reduced motion. PerlinDOM does not check the preference for you:
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) new PerlinDOM({ element, x: { min: -20, max: 20 } });

Troubleshooting

Nothing moves
Check that at least one of x / y is set, and that the element is not display: inline: transforms do not apply to inline boxes such as a plain <span>. Use inline-block or block. Static positioning is not the problem; PerlinDOM handles it.
The element jumps when it starts
Usually a CSS translate, such as translate(-50%, -50%) for centring, which PerlinDOM drops by default. Centre with left: calc(50% - half) or margins, or pass preserveBaseTranslation: true.
Too fast or too slow
Change speed: 0.001 is very slow, 0.01 is the default, 0.1 is fast. It can change live: inst.speed = 0.005.
Percentages do nothing
They are a share of the parent's size, so a parent with no width or height gives a range of 0. They also need an element; with element: null they cannot resolve.
It stopped while offscreen
That is the offscreen pause: its parent left the viewport. It resumes when the parent scrolls back into view; pass pauseOffscreen: false to keep it running.
pause() never finishes
lerpSpeed is 0 (or close to it). Use a value between 0.01 and 1.
My scale or rotate changed and the element ignores it
The CSS transform is read once at construction. Call refreshBaseTransform() after changing it.

Browser support

The npm build (dist/) targets the browsers that support native ES modules and ES2020: Chrome 87+, Edge 88+, Firefox 78+, Safari 14+. Importing the source file directly needs private class methods and logical assignment (??=): Chrome 85+, Edge 85+, Firefox 90+, Safari 15+.

  • requestAnimationFrame, getComputedStyle and CSS transforms are required.
  • DOMMatrixReadOnly separates the CSS translate from scale, rotate and skew; without it the computed transform is used as it is.
  • IntersectionObserver powers the offscreen pause; without it everything keeps running.