Depth Fade And Travelling Pulse
A lattice of dots bent around a shallow arc and lit by a pulse that travels across it. Depth is faked entirely from model-space z, which drives alpha and point size at the same time, so the arc reads as curved with no depth buffer and no perspective camera. The pulse is a cosine whose phase varies per column, and its crest also pushes dots toward the viewer, so the two techniques compound: the wave arrives as a moving band of brighter, larger dots.
Depth Fade And Travelling Pulse
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/depth-pulse-travelling-wave.jsonAlso installs webgl, prng, use-raf, 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
7 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| columns | number | = 110 | Columns across the arc. |
| rows | number | = 44 | Rows down each column. |
| arcSpan | number | = 110 | Arc the lattice is bent around, in degrees. |
| arcDepth | number | = 0.34 | Half-depth of the arc in model units — how far the edges recede. |
| fieldHeight | number | = 1.1 | Lattice height in model units. |
| pulseCycle | number | = 3 | Seconds per pulse. |
| waveTurns | number | = 1.4 | Wavelengths visible across the lattice at any instant. |
| waveDepth | number | = 0.18 | How far a crest pushes a dot toward the viewer, in model units. |
| yawAmplitude | number | = 16 | Peak yaw of the whole lattice, in degrees. |
| yawSpeed | number | = 0.25 | Yaw oscillations per second. |
| dotScale | number | = 1 | Dot radius multiplier in CSS pixels. |
| viewScale | number | = 0.46 | Model-to-pixel scale as a fraction of the container short side. |
| opacity | number | = 1 | Multiplier applied to every dot. |
| color | string | = '#6f757e' | Base dot colour. |
| crestColor | string | = '#ffffff' | Colour mixed in at the top of the crest. |
| seed | number | string | = 'depth-pulse-travelling-wave' | Seeds the sub-cell jitter for a stable, hydration-safe lattice. |
| className | string | — | Merged onto the host element via cn. |
How it works
The technique in order, not the code in order.
Columns are placed by angle across
arcSpan, then z is renormalised so the centre column lands at exactly+arcDepthand both edges at-arcDepth, independent of the span. Without that renormalisation the usable z range shrinks with the span and the depth cue silently weakens whenever the arc is flattened.The depth cue is
depthT = clamp((z + 0.55) / 1.1, 0, 1), which drives alpha from 0.25 to 1.0 *and* point size from 0.65 to 1.0. Both together are the point: alpha alone reads as a grey copy of the near dots, size alone reads as a resolution change, and only the pair reads as distance.The pulse is
raw = 0.65 + 0.35 * cos(2π * (uElapsed / pulseCycle - phase)), withphase = columnT * waveTurns + rowT * 0.12baked into an attribute. Subtracting the phase inside the cosine is what makes the crest move *along* the lattice rather than the whole field breathing in unison.The crest, renormalised to 0..1, is added to z as
p.z += crest * uWaveDepth, so the wave feeds the depth cue. A dot at the crest is pushed nearer, and the depth fade then brightens and enlarges it — one displacement drives three visible changes.The pulse ramps in over the first second (
clamp(uElapsed / 1.0, 0, 1)), so the first frame does not land on an arbitrary point of the cosine and flash. Yaw issin(elapsed * yawSpeed * 2π) * yawAmplitudedegrees, applied in the shader after the wave so the lattice rocks through its own depth range.gl_PointSizeis clamped to a 2px floor and alpha is compensated byunclamped² / clamped², so the field keeps the same apparent density at DPR 1 and DPR 2 instead of getting heavier on retina.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| PULSE_BASE / PULSE_SWING | = 0.65 / 0.35 | The pulse rides between 0.30 and 1.00 of nominal size and alpha. A trough at 0 would make dots vanish entirely and the lattice would look like it is dropping frames rather than breathing. |
| DEFAULT_CYCLE / pulseCycle | = 3.0 seconds | Under ~2s the crest outruns the eye following it across the lattice; over ~5s it stops reading as a wave and becomes an idle shimmer. |
| INTRO_SECONDS | = 1.0 | Ramps the pulse amplitude from 0. Without it the first rendered frame samples the cosine at an arbitrary phase and the whole field flashes on mount. |
| DEPTH_HALF_RANGE | = 0.55 | The arc spans ±0.34 and the wave adds up to +0.18, so normalising over ±0.55 leaves headroom at both ends — no dot sits at exactly 0 or 1 and the falloff never visibly clips. |
| DEPTH_MIN_SIZE / DEPTH_MAX_SIZE | = 0.65 / 1.0 | Far dots at 65% of near ones. Paired with the 0.25→1.0 alpha ramp; either cue on its own is ambiguous, and this ratio is the smallest that still reads as depth at preview-card sizes. |
| waveTurns | = 1.4 | Wavelengths visible across the lattice at any instant. At 1 the crest and trough sit at opposite edges and it reads as a tilt; past ~2.5 the bands are too narrow to track and the field just shimmers. |
| ROW_PHASE_LEAN | = 0.12 | Small row contribution to the phase, so the crest leans instead of arriving as a perfectly vertical bar. Bigger than ~0.3 and the wave starts reading as diagonal stripes. |
| yawAmplitude / yawSpeed | = 16 degrees / 0.25 Hz | The lattice rocks rather than spins. A full rotation would carry columns through the arc's entire depth range and destroy the near/far reading the crest depends on. |
| crest tint threshold | = smoothstep(0.65, 1.0, crest) | Only the top third of the crest picks up crestColor. Tinting the whole cosine spreads the accent across the field and the wave stops having a location. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No frame loop is opened. A single frame is drawn at uElapsed = 1.9 — past the one-second amplitude ramp, and a third of the way through the default cycle, so the crest sits inside the lattice rather than off its edge. Both the depth falloff and the wave band are visible in that still; neither advances.
Nulls the stage ref so a queued frame cannot touch a dead renderer, disposes the geometry through disposeObject(scene), disposes the material, calls disposeRenderer for dispose + forceContextLoss + a zeroed backing store, and removes the canvas. useRaf unsubscribes on unmount, so a StrictMode double-invoke leaves no orphaned context.
A curved lattice of dots is visibly dimmer and smaller toward its left and right edges than at its centre. A band of brighter, larger dots sweeps repeatedly across the field, roughly once every three seconds, rather than the whole field brightening at once. The lattice rocks gently side to side. Nothing flashes on mount. Resize the container and the field rescales without a flicker or a dropped context. With reduced motion it holds a single frame showing both the depth falloff and the crest.
Pitfalls
- Giving every dot the same phase. The field then pulses in unison, which reads as a loading state rather than as something moving through the lattice — the per-column phase offset *is* the effect.
- Fading alpha with depth but leaving point size constant. The far edge of the arc becomes a grey copy of the near edge instead of receding, because size is the cue the eye actually uses for distance at these scales.
- Leaving
depthTeston. Every dot is projected at z = 0, so whichever dot rasterises first wins the pixel and the lattice develops holes wherever it folds back on itself. - Starting the pulse at full amplitude. The first frame samples the cosine at whatever phase
elapsedhappens to be, and the field flashes on mount — hence the one-second amplitude ramp. - Placing columns by angle without renormalising z. The depth range then scales with
cos(arcSpan/2), so flattening the arc quietly kills the depth fade and the component looks broken at shallow spans while working at wide ones. - A perfectly regular lattice. It moirés against the pixel grid and shows banding as the field yaws; the seeded sub-cell jitter costs nothing and removes it.
- Rebuilding the buffer on resize. Model space is size-independent — only
uViewScaleanduPixelRatiochange, so a container reflow costs two uniform writes rather than a dropped GL context.
Notes
- Dot count is
columns * rows, 4840 at the defaults, in one draw call with one attribute beyond position. - The wave is purely a function of
uElapsedand a baked per-dot phase, so there is no simulation state and no drift — pausing and resuming lands exactly where the clock says it should.