Time delay
How to build time delay
Five capsules bounce in turn and look like a wave. That wave is one jump-and-landing profile, replayed by each capsule at its own time offset. That offset is what the time-delay drawer is. Skeleton code included — the article builds up from the assembly idea.
- Published
- June 14, 2026
- Topics
- Stagger · Time Delay · Easing · SVG
Set up one profile and a per-body time offset
- One jump-and-landing profile — rest → rise → apex → fall, then it wobbles and settles after landing
- Two co-channels riding the profile — a body that grows taller the faster it moves, and a droplet flicked off at landing
- A per-body time offset — replay the same profile delayed by
staggerFrames(this is the time delay)
const wrap = (value, span) => ((value % span) + span) % span;
// landing entry speed — from the fall curve's exit slope (it seeds the settle that follows)
const landVelocity = () => {
const [, , c2x, c2y] = fallBezier;
const slope = 1 - c2x > 0 ? (1 - c2y) / (1 - c2x) : 0;
return slope * (restCy - cyApex) / fallDur;
};
// height cy(tau): rest → rise → apex → fall, then it wobbles and settles after landing
const cyAt = (tau) => {
const amp = restCy - cyApex;
if (tau < riseDur) return restCy - amp * cubicBezier(...riseBezier)(tau / riseDur);
const landTau = riseDur + fallDur;
if (tau < landTau) return cyApex + amp * cubicBezier(...fallBezier)((tau - riseDur) / fallDur);
const t = tau - landTau;
const v = landVelocity();
return restCy + (v / settleOmega) * Math.exp(-settleLambda * t) * Math.sin(settleOmega * t);
};
// velocity vy(tau): the slope of the same curve. the stretch reads this
const vyAt = (tau) => {
const amp = restCy - cyApex;
if (tau < riseDur) return (-amp / riseDur) * cubicBezierSlope(...riseBezier)(tau / riseDur);
const landTau = riseDur + fallDur;
if (tau < landTau) return (amp / fallDur) * cubicBezierSlope(...fallBezier)((tau - riseDur) / fallDur);
const t = tau - landTau;
const v = landVelocity();
const e = Math.exp(-settleLambda * t);
return (v / settleOmega) * e * (settleOmega * Math.cos(settleOmega * t) - settleLambda * Math.sin(settleOmega * t));
};cyAt is the height profile. riseBezier carries the rise, an apex sits between, fallBezier carries the fall, and past the landing key it switches to a sine that wobbles and settles — the speed at the start of that sine is set directly by the landing entry speed (no wobble of its own is added). riseBezier and fallBezier are the same 4 numbers as CSS cubic-bezier(). The velocity vyAt is the slope of that same curve — how fast the height is changing — which you can get analytically or by finite-differencing the position.
// droplet: anchored at the separation frame, radius shrinks geometrically, cy drifts to its target
const satelliteAt = (sourceFrame, cx) => {
const half = periodFrames / 2;
const age = wrap(sourceFrame - separationFrame + half, periodFrames) - half;
if (age < -preSeparationFrames || age >= lifeFrames) return null;
return {
cx,
cy: cyAsymptote + (cyAnchor - cyAsymptote) * cyRatio ** age,
r: rAnchor * rDecay ** age,
};
};
function dotsAt(frame) { // frame number → each capsule's state; replay the same motion at a time offset
return Array.from({ length: dotCount }, (_, i) => {
const sourceFrame = wrap(frame - i * staggerFrames, periodFrames); // ← the time delay itself
const tau = wrap(sourceFrame - tLift, periodFrames);
const scaleY = 1 + stretchK * Math.abs(vyAt(tau));
const cx = dotCx0 + i * dotSpacing;
return {
cx,
cy: cyAt(tau),
width: sizeBasePx,
height: sizeBasePx * scaleY,
satellite: satelliteAt(sourceFrame, cx),
};
});
}This is the time delay itself. dotsAt just reads the same one profile, rewound per body by staggerFrames — no code gives any body its own motion. The wave of bounces is one motion laid out at slightly later starts. The vertical stretch reads the speed straight off (times stretchK), and satelliteAt flicks off one droplet at the landing.
Placement is five capsules, drawn as rounded rectangles at the returned cx, cy, width, height, plus a circle wherever there is a satellite. Drive it with requestAnimationFrame: multiply the elapsed time by your frame count, wrap against periodFrames, and hand the frame to dotsAt — you get the same loop as the demo.
To play with it, rewrite staggerFrames. Smaller packs the wave so the bodies move almost together; larger spreads the cascade one body at a time. Shift tLift and the bounce moves; swap the profile itself for a different rise and fall, and the skeleton that replays on a time offset keeps working.