Twelve dots, one ring
Each dot's target sits on a ring. Slide amplitude to 0.
Documentation
One class, a handful of options, organic motion on any element. Everything PerlinDOM does, and how to drive it.
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>
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.
Everything is optional. new PerlinDOM({ ... }) takes one object:
| Option | Type | Default | Description |
|---|---|---|---|
element | HTMLElement | null | null | The element to move. With null nothing is moved: the instance only computes lastX / lastY for you to use. |
x | { min, max } | null | null | Horizontal range, in px (numbers) or % of the parent (strings like '10%'). null keeps the axis still. |
y | { min, max } | null | null | Vertical range, same rules as x. |
speed | number | 0.01 | How far through the noise each frame travels. Lower is slower; 0.002 to 0.03 covers most uses. |
seed | number | 0 | Picks the path: it seeds the noise and is also the starting point in time. Same seed, same path. |
lerpSpeed | number | 0.1 | How quickly pause() and play() ease out and in, per frame. Keep it between 0 and 1; 0 never finishes easing. |
preserveBaseTranslation | boolean | false | By 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. |
amplitude | number | 1 | Strength 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 } | null | null | The 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. |
pauseOffscreen | boolean | true | Skip frames while the element's parent is outside the viewport. false keeps the instance running wherever it is. |
Numbers are px. The element moves the same distance at any screen size.
x: { min: -50, max: 50 }
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%' }
Each axis, and each end of a range, can use its own unit.
x: { min: '-5%', max: '5%' },
y: { min: -20, max: 20 },
Leave an axis out, or set it to null, and it does not drift.
new PerlinDOM({ element, x: { min: -100, max: 100 }, y: null });
All methods return undefined.
pause()lerpSpeed. Once fully stopped the instance leaves the animation loop and the element stays where it is.play()pause(), easing back in. A new instance is already playing, so you do not need to call it to start.destroy()transform.setTarget(target | null)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?)preserveBaseTranslation option.init(seed?)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.| Property | Type | Description |
|---|---|---|
amplitude | number | Read and write. Same clamping as the option: 0 to 1, non-numbers become 1. Safe to tween every frame. |
target | { x, y } | null | The current target. Read it; change it with setTarget(), which sanitizes the values. |
speed | number | Read and write, takes effect on the next frame. |
lerpSpeed | number | Read and write, used by the next pause() / play() ease. |
xRange, yRange | { min, max } | null | The x / y options, writable. Whether percentages are resolved is decided at construction, so switch between px values only, or build a new instance. |
lastX, lastY | number | The offset in px applied on the last frame, after amplitude. They hold still while paused or offscreen. |
isPlaying | boolean | false from pause() on, even while it is still easing out; true after play(). |
Each frame the noise gives an offset inside your ranges. amplitude blends that offset toward target:
offset = target + (noise - target) * amplitude
Give many elements targets that draw a shape, then lower amplitude: the drift settles into the shape. Try it:
Each dot's target sits on a ring. Slide amplitude to 0.
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.
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.
left/top, margins, grid or flex. A CSS translate is dropped unless you pass preserveBaseTranslation: true.destroy() before taking an element out of the page, or the instance keeps a reference to it.const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) new PerlinDOM({ element, x: { min: -20, max: 20 } });
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.translate(-50%, -50%) for centring, which PerlinDOM drops by default. Centre with left: calc(50% - half) or margins, or pass preserveBaseTranslation: true.speed: 0.001 is very slow, 0.01 is the default, 0.1 is fast. It can change live: inst.speed = 0.005.element: null they cannot resolve.pauseOffscreen: false to keep it running.lerpSpeed is 0 (or close to it). Use a value between 0.01 and 1.refreshBaseTransform() after changing it.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.