One absolutely positioned div per particle.
Every renderer runs the same simulation and the same controls, one at a time. Running them together would make them fight over one main thread, and every number would be wrong.
This is the control, and it is not a strawman. Elements are created once, colour, size and border radius are set once, and the per-frame work is a transform and an opacity, never top and left or a width.
Two things that look like optimisations are deliberately left out. Writing width and height each frame invalidates layout for every particle, so the pulse rides on the transform instead. And will-change is off: it buys a compositor layer per element, which helps for a handful and exhausts GPU memory at thirty thousand.
It still falls over long before the other two, which is why it stops at 12,000. Past that the main thread is busy for whole seconds at a time, the slider stops responding, and the tab is liable to be killed.