Organic Animations with @remotion/noise: Perlin Noise in Motion
Organic Animations with @remotion/noise: Perlin Noise in Motion
When you first start animating in Remotion, Math.sin() feels like a revelation. You can make anything pulse, wave, or oscillate with a single function. But watch any natural scene — leaves rustling, smoke curling, water rippling — and you will notice that real motion is never perfectly periodic. It has texture. It breathes. That quality is almost impossible to fake with a pure sine wave, and that is exactly the problem that Perlin noise was designed to solve.
The @remotion/noise package brings smoothly continuous, pseudo-random noise functions directly into your Remotion compositions. The result is animations that feel alive rather than mechanical. In this guide you will learn how noise functions work, why they beat Math.sin() for organic effects, and how to build four production-ready animation patterns: position jitter, particle systems, flowing gradient backgrounds, and wavy text.
What Is Perlin Noise and Why Does It Beat Math.sin()?
Ken Perlin invented his noise algorithm in 1983 while working on the original Tron film. The goal was to add naturalistic texture to computer-generated surfaces without the rigid periodicity of trigonometric functions.
Both Math.sin() and Perlin noise return a number that varies as you change the input. The critical difference is in how they vary:
Math.sin(t)repeats exactly every2πunits. The wave is perfectly predictable and looks mechanical at anything beyond a short loop.- Perlin noise at input
treturns a value that changes smoothly but never repeats in a recognisable pattern. Adjacent inputs always produce similar outputs (the function is continuous), but the shape is aperiodic and feels spontaneous.
Put concretely: Math.sin(frame * 0.1) will oscillate back and forth like a metronome. noise2D(1, frame * 0.05) will drift, linger, reverse, drift again — it feels like something breathing.
Installing @remotion/noise
npm i @remotion/noise
Import the functions you need:
import { noise2D, noise3D, noise4D } from "@remotion/noise";
All three functions return a value between -1 and 1.
| Function | Signature | Use case |
|---|---|---|
noise2D | noise2D(seed, x) | Time-varying 1D motion (jitter, scale, rotation) |
noise3D | noise3D(seed, x, y) | 2D spatial fields (particle velocity grids) |
noise4D | noise4D(seed, x, y, z) | 3D spatial fields animating over time |
The seed parameter lets you produce multiple independent noise streams from the same frame counter. Pass different integers (0, 1, 2, 42, 100 …) to get uncorrelated streams.
Pattern 1: Position Jitter for Text and Icons
The simplest and most impactful use of noise is adding a subtle, living jitter to otherwise static elements. This is the technique behind the “floating” feel you see in motion design title cards.
import { AbsoluteFill, useCurrentFrame } from "remotion";
import { noise2D } from "@remotion/noise";
export const FloatingTitle: React.FC = () => {
const frame = useCurrentFrame();
// Two independent noise streams for X and Y
const x = noise2D(1, frame * 0.03) * 12; // ±12px horizontal
const y = noise2D(2, frame * 0.03) * 8; // ±8px vertical
const rotation = noise2D(3, frame * 0.02) * 3; // ±3deg
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
backgroundColor: "#0a0a0f",
}}
>
<h1
style={{
fontFamily: `-apple-system, "Segoe UI", Roboto, sans-serif`,
fontSize: 96,
color: "#ffffff",
transform: `translate(${x}px, ${y}px) rotate(${rotation}deg)`,
margin: 0,
letterSpacing: "-2px",
}}
>
Hello, World
</h1>
</AbsoluteFill>
);
};
Why this looks good: The three noise streams use different seeds (1, 2, 3) so X, Y, and rotation are uncorrelated. The slow speed factor (0.03) keeps the motion gentle. You can increase it to 0.1 for nervous energy, or reduce it to 0.01 for a barely-perceptible living quality.
Pattern 2: Particle System Driven by a Noise Field
A noise field assigns a velocity vector to every point in 2D space. Particles follow those vectors and produce the flowing, curling motion characteristic of smoke, flocking birds, and ocean currents.
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
import { noise3D } from "@remotion/noise";
const PARTICLE_COUNT = 80;
// Deterministic initial positions
const particles = Array.from({ length: PARTICLE_COUNT }, (_, i) => ({
x: (i * 37.3) % 1920,
y: (i * 59.7) % 1080,
seed: i,
}));
export const NoiseParticles: React.FC = () => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const t = frame * 0.008; // time scroll speed through noise space
return (
<AbsoluteFill style={{ backgroundColor: "#050510" }}>
<svg width={width} height={height}>
{particles.map((p, i) => {
// Accumulate position by sampling velocity from noise field
let px = p.x;
let py = p.y;
for (let f = 0; f < frame; f++) {
const ft = f * 0.008;
const vx = noise3D(p.seed, px / 300, ft) * 2.5;
const vy = noise3D(p.seed + 1000, py / 300, ft) * 2.5;
px += vx;
py += vy;
// Wrap around canvas
px = ((px % width) + width) % width;
py = ((py % height) + height) % height;
}
const opacity = 0.3 + (noise3D(p.seed + 2000, frame * 0.02, 0) + 1) * 0.35;
return (
<circle
key={i}
cx={px}
cy={py}
r={2.5}
fill="#7eb8f7"
opacity={opacity}
/>
);
})}
</svg>
</AbsoluteFill>
);
};
Performance note: The inner
forloop recomputes the full trajectory from frame 0 every render. For short compositions this is fine. For longer ones, consider persisting state withuseRefor precomputing trajectories incalculateMetadata.
Pattern 3: Flowing Gradient Background
Noise-driven colour gradients produce backgrounds that feel like slow, shifting light — perfect for moody title sequences or ambient b-roll plates.
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
import { noise2D } from "@remotion/noise";
export const FlowingGradient: React.FC = () => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const t = frame * 0.012;
// Sample noise to smoothly shift hue and position of gradient stops
const hue1 = interpolate(noise2D(10, t), [-1, 1], [200, 280]);
const hue2 = interpolate(noise2D(11, t), [-1, 1], [280, 360]);
const hue3 = interpolate(noise2D(12, t), [-1, 1], [160, 220]);
const angle = interpolate(noise2D(13, t), [-1, 1], [120, 240]);
const stop1 = interpolate(noise2D(14, t), [-1, 1], [0, 30]);
const stop2 = interpolate(noise2D(15, t), [-1, 1], [40, 70]);
const gradient = `linear-gradient(
${angle}deg,
hsl(${hue1}, 70%, 30%) ${stop1}%,
hsl(${hue2}, 65%, 20%) ${stop2}%,
hsl(${hue3}, 75%, 15%) 100%
)`;
return (
<AbsoluteFill style={{ background: gradient }}>
{/* Your content layers go here */}
</AbsoluteFill>
);
};
The key insight is using interpolate() to map noise values from the [-1, 1] range into sensible HSL parameters. Because adjacent noise values are always close together, the gradient shifts feel continuous and smooth — never a sudden jump.
Pattern 4: Wavy Text with Per-Character Noise
Per-character transforms applied via noise create the “wave text” effect that became popular in motion graphics around 2020 and has not lost its appeal.
import { AbsoluteFill, useCurrentFrame } from "remotion";
import { noise2D } from "@remotion/noise";
const TEXT = "ORGANIC MOTION";
export const WavyText: React.FC = () => {
const frame = useCurrentFrame();
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
backgroundColor: "#111",
}}
>
<div style={{ display: "flex", gap: 4 }}>
{TEXT.split("").map((char, i) => {
const t = frame * 0.04 + i * 0.35; // stagger by character index
const translateY = noise2D(i * 17 + 1, t) * 28;
const scale = 1 + noise2D(i * 17 + 2, t) * 0.18;
const rotation = noise2D(i * 17 + 3, t) * 8;
return (
<span
key={i}
style={{
fontFamily: `-apple-system, "Segoe UI", Roboto, sans-serif`,
fontSize: 80,
fontWeight: 700,
color: "#fff",
display: "inline-block",
transform: `translateY(${translateY}px) scale(${scale}) rotate(${rotation}deg)`,
transformOrigin: "bottom center",
}}
>
{char === " " ? " " : char}
</span>
);
})}
</div>
</AbsoluteFill>
);
};
The stagger factor i * 0.35 in the noise input ensures adjacent characters are at different positions along the noise curve, so they move independently rather than in lockstep.
Tuning Noise Parameters
| Parameter | Effect | Typical range |
|---|---|---|
Speed multiplier (frame * X) | How fast the value changes | 0.01 (glacial) – 0.15 (frantic) |
Output scale (* Y) | Amplitude of the effect | Depends on unit: 5–30px, 1–15deg |
| Different seeds per axis | Independence of simultaneous streams | Always use different integers |
| Spatial frequency (divide coords) | “Zoom” into or out of noise space | /100 (smooth) – /10 (noisy) |
A useful debugging trick: temporarily replace noise2D with Math.sin using the same time argument. If the sine version looks right but too mechanical, you have dialled in the speed and amplitude correctly — just switch back to noise.
Noise vs. Spring vs. Interpolate: Choosing the Right Tool
spring(): Best for single-trigger transitions (element entering the screen). Settles to a target value and stops.interpolate(): Best for scrubbing between two known states across a fixed range of frames.noise2D(): Best for continuous, aperiodic motion that never quite repeats. Not suited for animations that need to reach a specific end state.
They compose well: you might use interpolate to bring an element on screen, then switch to layering a subtle noise2D jitter on top once it has arrived.
Organic Blob Shape with Noise-Driven SVG Path
import { AbsoluteFill, useCurrentFrame } from "remotion";
import { noise2D } from "@remotion/noise";
export const OrganicBlob: React.FC = () => {
const frame = useCurrentFrame();
const cx = 960;
const cy = 540;
const baseRadius = 220;
const POINTS = 12;
// Build a closed SVG path from noise-offset radial points
const points = Array.from({ length: POINTS }, (_, i) => {
const angle = (i / POINTS) * Math.PI * 2;
const t = frame * 0.025;
const radiusNoise = noise2D(i * 100 + 1, t) * 60;
const r = baseRadius + radiusNoise;
return {
x: cx + Math.cos(angle) * r,
y: cy + Math.sin(angle) * r,
};
});
// Create a smooth closed path with cubic bezier curves
const d = points
.map((p, i) => {
const next = points[(i + 1) % POINTS];
const mx = (p.x + next.x) / 2;
const my = (p.y + next.y) / 2;
return i === 0 ? `M ${mx},${my}` : `Q ${p.x},${p.y} ${mx},${my}`;
})
.join(" ") + " Z";
return (
<AbsoluteFill style={{ backgroundColor: "#0d0d18" }}>
<svg width={1920} height={1080}>
<defs>
<radialGradient id="blobGrad">
<stop offset="0%" stopColor="#7eb8f7" />
<stop offset="100%" stopColor="#3a6fd8" stopOpacity={0} />
</radialGradient>
</defs>
<path d={d} fill="url(#blobGrad)" opacity={0.6} />
</svg>
</AbsoluteFill>
);
};
Quick Reference: Common Noise Recipes
// Gentle float (position)
const y = noise2D(1, frame * 0.025) * 15;
// Breathing scale
const scale = 1 + noise2D(2, frame * 0.02) * 0.08;
// Subtle rotation wobble
const rot = noise2D(3, frame * 0.018) * 4;
// Opacity pulse
const opacity = interpolate(noise2D(4, frame * 0.03), [-1, 1], [0.6, 1]);
// Colour temperature shift (using noise to drive hue)
const hue = interpolate(noise2D(5, frame * 0.01), [-1, 1], [210, 250]);
Pre-Built Templates on RenderComp
Building noise-driven effects from scratch is rewarding, but it takes time to get the parameters right. At RenderComp we have already tuned dozens of organic animation components — floating particle fields, noise-driven lower thirds, ambient gradient backgrounds, and more — all available as ready-to-customise Remotion templates. Browse the library at rendercomp.com to drop production-quality organic motion into your next project in minutes.
Frequently Asked Questions
Q1: Does @remotion/noise work in the Remotion Player (browser preview)?
Yes. The package is a pure JavaScript library with no native dependencies. It runs identically in the browser preview, the Studio, and during server-side rendering with renderMedia.
Q2: What is the difference between noise2D, noise3D, and noise4D?
The number refers to how many coordinate arguments the function accepts beyond the seed. noise2D(seed, x) takes one coordinate — typically time — and produces a 1D stream. noise3D(seed, x, y) accepts two spatial coordinates, making it perfect for 2D velocity fields. noise4D adds a third coordinate, useful for 3D fields that also evolve over time.
Q3: Will noise produce the exact same values every render (determinism)? Yes. Perlin noise is a deterministic function. The same seed, x, y, z inputs always return the same output. Remotion’s rendering model relies on frame-determinism, so this is essential. You will always get identical frames on re-render.
Q4: How do I stop the noise from feeling too random or jittery?
Reduce the speed multiplier (e.g. from frame * 0.1 down to frame * 0.025) and reduce the output scale. Slower speed through noise space = smoother, more intentional-feeling motion. Think of it as zooming out on the noise landscape.
Q5: Can I combine noise with spring() animations?
Absolutely. A common pattern is to use spring() to animate an element onto screen (a controlled entry), and then add a noise-driven offset on top once it has settled. Use addition: translateY = springValue + noise2D(1, frame * 0.03) * 10.
Q6: The particle system is slow in the Studio. What can I do?
The naive particle accumulation loop is O(frame × particleCount). Two optimisations help: (1) reduce PARTICLE_COUNT; (2) move trajectory computation to calculateMetadata and pass the pre-computed positions as props. For very long compositions, consider rendering in a lower-resolution preview pass first.
Q7: Is there a way to make the noise “reset” or loop cleanly?
Pure Perlin noise does not loop. For looping noise, sample along a circle in noise space: noise3D(seed, Math.cos(t) * r, Math.sin(t) * r) where t = (frame / totalFrames) * 2 * Math.PI. At integer multiples of 2π the sample returns to the same point, producing a seamless loop.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →