Radial Ripple Dot Cloud
Several thousand dots in one draw call, with a wave travelling outward from a point left of centre. The displacement is a sine in radius minus a phase in time, attenuated exponentially, so the field is violent near the source and nearly still at the rim — which is what gives the motion an origin rather than making it a screensaver. Dot density along the radius is a hand-placed cubic B-spline, so the rings and gaps in the layout are drawn rather than derived from a formula.
Radial Ripple Dot Cloud
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/radial-ripple-dot-cloud.jsonAlso installs webgl, prng, damp, use-raf, use-in-view, use-visibility, use-resize-observer, use-reduced-motion from this registry.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
10 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| count | number | = 6000 | Dots kept after density rejection. One draw call regardless. |
| size | number | = 2.6 | Base dot diameter in CSS pixels, before the per-dot density scale. |
| frequency | number | = 6.5 | Radians of wave per field unit; wavelength is 2π / frequency. |
| speed | number | = 2.4 | Radians of phase per second. Crest speed is speed / frequency. |
| decay | number | = 0.85 | Exponential attenuation per field unit of radius. |
| amplitude | number | = 0.045 | Peak displacement in field units, where the field is 2 units tall. |
| centerX | number | = 0.4 | Ripple source across the field, 0..1. Deliberately not centred. |
| centerY | number | = 0.5 | Ripple source down the field, 0..1. |
| color | string | = '#5d6b7a' | Dot colour in the troughs. |
| crestColor | string | = '#eaf2ff' | Dot colour on an outgoing crest. |
| opacity | number | = 1 | Multiplier applied after the per-dot area compensation. |
| seed | number | string | = 'radial-ripple-dot-cloud' | Seeds rejection sampling, so the layout is stable across reloads and matches the server. |
| className | string | — | Merged onto the host element via cn. |
How it works
The technique in order, not the code in order.
Rest positions are baked once, in field units, into an
aOriginattribute: the field spans ±16/9 horizontally and ±1 vertically. The draw-time uniformuCover = max(1, aspect / (16/9))scales that field to cover the camera, so a resize writes one float instead of rebuilding a buffer.Density along the radius comes from a uniform cubic B-spline over eight hand-placed control points — five spans — evaluated in JS at bake time.
bsplineWeights(t)returns the four basis values[(1-t)³, 4-6t²+3t³, 1+3t+3t²-3t³, t³] / 6andsampleRampblends control pointsi..i+3. The curve is C2 and does not pass through its control points, which is what lets a hand-placed sequence stay smooth.Positions are chosen by rejection sampling: a seeded candidate uniform over the field rectangle is kept with probability
ramp(dist / maxRadius), wheremaxRadiusis the distance from the ripple source to the farthest field corner. That is the only way to realise an arbitrary drawn radial density from a uniform PRNG without inverting the curve analytically.The same ramp value is baked into
aScale(0.55 + 0.75d) andaAlpha(0.3 + 0.7d), so a sparse band is also a dim band. Two independent curves would let them disagree and the field would read as noise with holes in it.The vertex shader computes
dist = length(origin - uCenter),wave = sin(dist * uFrequency - uElapsed * uSpeed)— a travelling wave, because the time term is subtracted from the radius term — andattenuated = wave * exp(-dist * uDecay), then displaces each dot along its own outward direction byattenuated * uAmplitude.The direction is guarded:
dir = dist > 1e-5 ? delta / dist : vec2(0.0), because normalising a zero vector at the exact source yields NaN and one NaN vertex takes the whole point with it.gl_PointSizeis floored at 2 device pixels and the alpha is divided by the area that flooring invented, so the cloud has the same density on a 1x monitor as on a 3x phone. The fragment shader cuts a disc with1 - smoothstep(0.42, 0.5, d)and tints towarduCrestColorby the signed wave, so only the outgoing crest lights up.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| FIELD_HALF_W | = 16 / 9 | Half-width of the baked field, with the field 2 units tall. Baking at one fixed aspect is what makes a resize a single uniform write; 16:9 is the aspect most containers are near, so uCover is usually 1 and nothing is scaled at all. |
| DENSITY_RAMP | = [1, 1, 0.34, 0.28, 0.92, 0.5, 0, -0.15] | Eight control points, so five spans. Sampled it reads 0.89 / 0.44 / 0.40 / 0.74 / 0.49 / 0.06 — dense core, gap, secondary band at ~60% radius, empty rim. The last point is negative deliberately: a control point is not a value, and pulling it below zero is what makes the tail actually reach zero instead of settling around 0.15. |
| centerX | = 0.4 | Ripple source at 40% of the width rather than 50%. A centred radial wave is bilaterally symmetric and the eye reads symmetry as a screensaver; a source off the axis reads as an impact somewhere in the scene. |
| frequency | = 6.5 rad per field unit | Wavelength 2π/6.5 ≈ 0.97 units, about half the field height, so roughly two and a half crests fit between the source and the far corner. Past ~12 the rings alias against the dot spacing and the field boils; below ~3 there is only one crest and no ripple to read. |
| speed | = 2.4 rad per second | Crest speed is speed / frequency = 0.37 field units per second, so a crest crosses the field in about six seconds. Fast enough to be motion, slow enough that the eye can follow a single ring outward. |
| decay | = 0.85 per field unit | Amplitude is 43% at one unit from the source and 13% at the far corner, which gives the wave a source without a visible boundary. A linear falloff reaches exactly zero at a fixed radius and draws a hard circle there. |
| amplitude | = 0.045 field units | 2.25% of the field height — around 25px on a 1100px-tall field, in the same range as the 18px the technique was observed at. Past ~0.12 dots cross each other and the cloud shears instead of rippling. |
| size | = 2.6 CSS pixels | Base diameter, scaled 0.55..1.3 per dot. Under ~2 the hardware point-size floor kicks in for most dots and the alpha compensation is doing all the work; over ~5 the dense core fills solid and the ripple stops being legible. |
| count | = 6000 | One interleaved-free buffer, one draw call. At this size the field is vertex-bound rather than fill-bound, and 6000 is roughly where the core reads as continuous without the gaps needing more dots to hide them. |
| MAX_ATTEMPTS_PER_DOT | = 24 | Rejection-sampling budget. The mean of the ramp is about 0.45, so ~2.2 attempts per kept dot; a 24x budget absorbs a much sparser custom curve and still terminates rather than spinning forever if someone authors a ramp that is zero everywhere. |
| STATIC_ELAPSED | = 3.4 s | The frame drawn under reduced motion. Far enough in that two crests have left the source, so the still frame shows the ripple structure rather than the undisturbed lattice. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The component reads useReducedMotion itself and opens no rAF subscription. One frame is drawn at uElapsed = 3.4, by which point two crests have left the source — so the still image shows the ripple structure and the drawn density bands, not the undisturbed lattice. The layout, the colours and the crest tinting are all present; only the travel stops. The frame is re-drawn on resize because a canvas resize clears the buffer.
Nulls the stage ref, walks the scene with disposeObject to release the geometry and its four attribute buffers, disposes the ShaderMaterial explicitly, then calls disposeRenderer — dispose plus forceContextLoss plus a zeroed drawing buffer — and removes the canvas. The canvas is created inside the effect and appended imperatively rather than being owned by a React ref, so a StrictMode double-mount builds a fresh context instead of handing back the one that was just killed.
Rings of dots visibly travel outward from a point left of centre, roughly two crests visible at a time, brightening as each crest passes and dimming behind it. The rim of the field barely moves. The layout has obvious drawn structure — a dense core, a gap, a second band — and is identical after a reload and after resizing the window. With reduced motion the field renders in full and holds a frozen mid-ripple frame.
Pitfalls
- Writing
sin(dist * frequency) * sin(elapsed * speed). That is a standing wave: the rings pulse in place and never travel. Subtracting the phase from the radius inside one sine is what makes crests move outward — and adding it instead sends them inward, which reads as a drain rather than an impact. - Normalising the offset vector without guarding the source. At
dist == 0the division produces NaN, the vertex is discarded by the rasteriser, and on some drivers the whole primitive goes with it — an intermittently missing dot that is very hard to attribute. - Attenuating the radius instead of the wave, or using a linear falloff. Linear reaches zero at a fixed radius and draws a visible circle across the field; the exponential never quite reaches zero and has no edge.
- Rebuilding the position buffer on resize. It reallocates hundreds of kilobytes during a window drag and, if the layout is re-sampled, re-randomises every dot mid-motion. Baking in field units and scaling by one
uCoveruniform avoids both. - Leaving
frustumCulledat its default. Every vertex is displaced in the shader, so three's bounding sphere — computed from the undisplaced attribute — is wrong, and the whole cloud can vanish when the camera or the container changes. - Using
Math.random()for the layout. The server and the client disagree, so React logs a hydration mismatch, and the composition a designer approved is not the one the next visitor sees. - Treating the B-spline control points as points on the curve. The curve does not pass through them — the value at the start of the first span is
(P0 + 4P1 + P2) / 6. Authoring the ramp as if it were interpolating produces a flatter, wrong curve and hours of confusion. - Driving dot brightness from a second curve independent of the density curve. Where they disagree the field reads as random noise with holes in it rather than as drawn bands.
- Forgetting that a resize clears the drawing buffer. Under reduced motion nothing redraws afterwards, so the component is blank after any reflow unless the frozen frame is re-rendered.
Notes
- The camera is orthographic, ±aspect by ±1, so field units are directly comparable to the container height: 1 unit is half the visible height.
uCoverimplements cover, not contain. Past 16:9 the baked field would otherwise stop short of the container edge and leave a visible vertical seam where the dots end.- The ripple runs on absolute elapsed time from the shared rAF clock, so several instances on one page stay in phase with each other.