Offset
How to build offset
Five circles slide left, and a wave of sizes seems to travel right to left. That wave is one move, layered with each copy delayed by a little. That stagger is what 'offset' is. Skeleton code included — the article builds up from the assembly idea.
- Published
- June 14, 2026
- Topics
- Offset · Stagger · Easing · SVG
One move, and the staggered offsets that layer it
- You hand-author one move — a single dot sliding left — and the run of sizes, one per slot
- You layer that one move, each copy delayed by a little — that is “offset”
- One copy heading right carries a single size bumped up a touch
const wrap = (value, span) => ((value % span) + span) % span;
// frame number → how far along (0 to 1). Each slide advances forward, then holds
const progressAt = (frame) => {
const local = wrap(frame, periodFrames);
const subcycle = Math.floor(local / subcycleFrames);
const within = local - subcycle * subcycleFrames;
return cubicBezier(...easeHandles)(Math.min(within / activeFrames, 1));
};progressAt returns one number that moves from 0 to 1 over each slide. As you'll see, that same number moves both the slide (position) and the size of every circle. The easeHandles you pass to cubicBezier are the same set as CSS cubic-bezier(), and drop straight into npm's bezier-easing. It's clamped, so it advances only for activeFrames, then holds until the next one.
const slotCount = slotKeys.length;
const dupCount = Math.round(periodFrames / subcycleFrames);
// size for a slot. In just one of the time-shifted copies, one right-hand slot returns a different value
const valueOf = (slot, dup) => {
if (dup === overrideDup && slot === overrideSlot) return overrideValue;
return slot >= 0 && slot < slotCount ? slotKeys[slot] : 0;
};
function dotsAt(frame) { // frame number → each slot's position and size. One progress decides both
const subcycle = Math.floor(wrap(frame, periodFrames) / subcycleFrames);
const progress = progressAt(frame);
return slotKeys.map((_, slot) => {
const dup = wrap(subcycle + slot - (slotCount - 1), dupCount);
return {
coord: anchor + spacing * (slot - progress),
value: valueOf(slot, dup) * (1 - progress) + valueOf(slot - 1, dup) * progress,
slot,
};
});
}This is what offset really is. dotsAt doesn't draw separate waves — it picks a copy with dup and reads one progress at a time offset, nothing more. The “wave of sizes” travelling right to left is just the same move layered with small delays. The way a size looks like it eases back to normal on the way in is only that one bumped value being interpolated toward the shared neighbour — there's no separate falloff step.
Placing it is just circles. Take the coord dotsAt returns as the x, the value as the radius, and line them up on one baseline. Slots whose size is near 0 are dropped. Drive it with requestAnimationFrame: multiply elapsed time by your frame count to get a frame number, wrap it against periodFrames, and hand it to dotsAt — you get the same loop as the demo. Progress comes full circle over periodFrames and the copy assignment returns to its start over one lap, so there's no visible seam.
To play with it, rewrite the run of sizes. Change the numbers in slotKeys and the wave's peak rises or falls; change spacing and the slide step changes. Delete the one bumped value (overrideValue) and the wave flows at the same size everywhere. Shrink subcycleFrames and both the slide and the spacing between copies get busier. Since it takes the page's own ink rather than a hard-coded colour, you can swap the circles for squares and the same skeleton keeps working.