Make a field of dots that pulse in a traveling diagonal wave. Unlike an orbit, the dots stay in place: the sense of movement comes from neighboring dots reaching the same size at different times. This technique transfers to repeating patterns, rhythmic backgrounds, and coordinated groups of elements.
Before you start
Allow about 20 minutes. You should be comfortable with JavaScript variables, loops, and the frame callback. If those are new, complete Create a looping animation first.
Use a square project with a six-second duration. Add a Code layer, make its clip cover the composition, and hide other visual layers while you work. Replace the whole source with each complete example instead of appending callbacks.
1. Place a static grid
Paste this complete example into the code editor:
const columns = 9;
const rows = 9;
const margin = 0.14;
motion.onFrame(() => {
motion.draw.clear();
motion.draw.setBackground("#101827");
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const x = margin + (column / (columns - 1)) * (1 - margin * 2);
const y = margin + (row / (rows - 1)) * (1 - margin * 2);
motion.draw.circle(x, y, 0.018, {
color: "#8ce8ce",
fillOnly: true,
});
}
}
});
You should see 81 evenly spaced mint dots with clear margins around the grid. Their centers run from 0.14 to 0.86 on each axis. Dividing by columns - 1 makes the last column land on the far boundary rather than one step short of it.
Keep both counts at least two: this layout divides by one less than each count. A square frame gives equal horizontal and vertical spacing for these settings.
2. Give each dot a phase offset
Replace the source with this complete animated version:
const columns = 9;
const rows = 9;
const margin = 0.14;
const minRadius = 0.009;
const maxRadius = 0.032;
const phaseStep = 0.55;
motion.onFrame(() => {
motion.draw.clear();
motion.draw.setBackground("#101827");
const phase = (motion.time / Math.max(0.001, motion.duration)) * Math.PI * 2;
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const x = margin + (column / (columns - 1)) * (1 - margin * 2);
const y = margin + (row / (rows - 1)) * (1 - margin * 2);
const offset = (column + row) * phaseStep;
const pulse = 0.5 + 0.5 * Math.sin(phase - offset);
const radius = minRadius + pulse * (maxRadius - minRadius);
motion.draw.circle(x, y, radius, {
color: (column + row) % 3 === 0 ? "#ffd18a" : "#8ce8ce",
fillOnly: true,
});
}
}
});
Start playback. A band of larger dots should travel diagonally across the grid, while each dot expands and contracts once over the six-second composition. Gold diagonals stay attached to the same dots rather than flickering between frames.
The sine function produces values from -1 to 1. Multiplying by 0.5 and adding 0.5 maps that range to 0 through 1. The radius then interpolates between minRadius and maxRadius, so it stays positive and bounded.
The offset depends only on a dot's location. All dots share the same repeating timeline phase, but neighboring dots reach their largest size at different moments. No timers or accumulated per-frame state are needed.
3. Compare three kinds of rhythm
Change only the offset expression, then play a full cycle:
const offset = 0;makes every dot breathe together. The grid reads as one object.const offset = column * phaseStep;makes columns pulse together. The wave travels horizontally.const offset = (column + row) * phaseStep;restores the diagonal wave.
Next try phaseStep = 0.25 instead of 0.55. Adjacent dots are closer in phase, producing a broader band of similar sizes. A spatial phase offset does not need to be a whole number: it stays constant over time. By contrast, multiplying the time-based phase by a fractional number can break the loop.
For two pulses per composition, use Math.sin(phase * 2 - offset) in the pulse calculation. Keep that time multiplier a whole number when the animation must repeat seamlessly.
4. Check spacing and the loop
With nine columns, the horizontal center spacing is (1 - 2 * 0.14) / 8, or 0.09. The largest dot diameter is 2 * 0.032, or 0.064, leaving room between neighbors on a square frame.
If you add more rows or columns, reduce maxRadius to keep the grid open. Overlap can be intentional, but it should be a design choice rather than an accidental consequence of a denser grid.
Scrub to one quarter and three quarters of the duration. The sizes should differ. Return to one quarter: the same sizes should return. Watch the end-to-start transition during playback; each dot should continue its pulse without a sudden change.
Use Export an animation to render the result and check it with repeat enabled. Check the smaller dots at the intended display size; if they become indistinct, increase the minimum radius or reduce the grid density.
Troubleshooting
All dots pulse together. Check that offset varies by row or column and is subtracted inside Math.sin().
The grid looks stretched. The positions are normalized to each axis independently. Use a square frame for this exercise; a rectangular frame changes the spacing, while the circles remain circular.
The dots overlap. Reduce maxRadius, decrease the number of dots, or increase the available grid area. Check diameter against the smaller center spacing.
The loop jumps. Restore the full-cycle phase formula and use an integer time multiplier. Confirm the layer's clip spans the full composition.
Apply the technique elsewhere
The reusable idea is a shared phase plus a stable offset for each element. You can use the same pulse to control other bounded properties, while keeping each frame reproducible from timeline time. Read Timeline time and loops for the loop model and Drawing API basics for coordinate behavior.