Parallax
How to build parallax
Seven circles bob up and down and look like they sit at different depths. That depth read comes from one thing: every circle shares the same waveform, and the travel amount changes from circle to circle. The wider a circle bobs, the nearer it reads; the smaller, the farther. Skeleton code included — the article builds up from the assembly idea.
- Published
- June 13, 2026
- Topics
- Parallax · Amplitude · Easing · SVG
Set up one waveform and a per-circle travel
- Seven fixed circles — position and size locked, only the vertical moves
- One shared waveform — top → bottom → top across 3 keys, zero speed at every key, a separate easing curve for the descent and the ascent
- A per-circle travel
ampand centremid— multiply the shared wave by its ownampand bob frommid
// All that moves is one vertical wave; the 7 circles each read it by their own travel.
// The bare lowercase names (periodFrames, bottomKeyFrame) are numbers you choose for
// your own screen — what this article is for is the shape, not the values.
const wrap = (value, span) => ((value % span) + span) % span;
const sharedWave = (designTime) => { // top → bottom → top; the descent and ascent carry separate curves
const descEase = cubicBezier(...descHandles); // descent (top→bottom) easing
const ascEase = cubicBezier(...ascHandles); // ascent (bottom→top) easing
const t = wrap(designTime, periodFrames);
if (t <= bottomKeyFrame) return -1 + 2 * descEase(t / bottomKeyFrame); // top to bottom
return 1 - 2 * ascEase((t - bottomKeyFrame) / (periodFrames - bottomKeyFrame)); // bottom to top
};
function dotsAt(frame) { // frame number → where the 7 circles go; compute the wave, read each by travel
const wave = sharedWave(frame + capturePhase);
return circles.map((c) => ({
cx: c.cx,
cy: c.mid + c.amp * wave,
r: c.r,
}));
}descEase and ascEase hold the face of this motion. They are the same 4 numbers as CSS cubic-bezier(), and what you put in descHandles and ascHandles goes straight into npm's bezier-easing. The wave runs between a top end and a bottom end, and the curve fills the space between. The descent (top→bottom) and the ascent (bottom→top) carry separate curves on purpose — reuse one for both and the miss against the original video grows clearly.
The seven bobbing circles all read the same sharedWave. The only thing that differs is each circle's amp — no code staggers their starts, and no circle gets its own wave. Bigger circles look like they travel more, but there is no law tying size to travel — each amp is a value chosen by hand. In the original video, too, the size-to-travel ratios are scattered, never a clean proportion.
Placement is just seven circles drawn at the returned cx, cy, r. Drive it with requestAnimationFrame: multiply the elapsed seconds by your frames-per-second, take the remainder against periodFrames, and hand the frame to dotsAt — you get the same loop as the demo above. The wave returns exactly to the starting top at the end of one lap, so the loop has no visible seam.
To play with it, rewrite amp. Make every amp equal and the depth vanishes into a plain unison bob; scatter them and the depth returns. Point the wave at scale or opacity instead of the vertical position, and the skeleton that shares one waveform keeps working.