Two sines, plotted against each other on a 2D canvas. Don't clear the canvas between frames. That's the whole effect. Everything else is decoration on top of those two facts.
The shape comes from Lissajous figures, the curve you get when you feed one oscillating waveform into the X deflection of a cathode-ray tube and another into the Y. The glow comes from phosphor persistence, the property that made CRT screens look analog: the screen keeps emitting light for a moment after the beam moves on. We fake both.
A.k.a. Lissajous
Named for Jules Antoine Lissajous, who studied them in the 1850s by reflecting light off mirrors attached to tuning forks. The parametric form is two sines:
x(t) = sin(a·t + δ)
y(t) = sin(b·t)
Three parameters. a is how often the X coordinate cycles per unit of t; b is the same for Y; δ is the phase offset between them. Plug those
into a 2D plot and step t from 0 to 2π·max(a, b) to draw a full loop. Drag the
knobs below to feel it.
The ratio a:b determines the figure's shape. 1:1 with δ = π/2 is a circle; 1:1
with δ = 0 is a diagonal line. 1:2 is a figure-8. 2:3 is a trefoil. The numerator counts X
loops per period, the denominator counts Y loops. As the integers grow, the figure gets
denser. Irrational ratios fill the box uniformly and never quite close, which is its own
visual signature.
The phase δ controls how the two oscillations line up. Slide it from 0 to π/2 on a 1:2 figure and you see the figure-8 dissolve into a parabola, then into a tilted figure-8 on the other side. The shape is alive in three dimensions of parameter space.
1// One frame's worth of curve. Two sines, orthogonal axes.2const N = 3000;3const coverage = 2 * Math.PI * Math.max(Math.ceil(a), Math.ceil(b));4const step = coverage / N;5 6ctx.beginPath();7for (let i = 0; i <= N; i++) {8 const t = i * step;9 const x = cx + Math.sin(a * t + delta) * size;10 const y = cy + Math.sin(b * t) * size;11 if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);12}13ctx.stroke(); Phosphor persistence
A static Lissajous is a math plot. The CRT look is what you get when you stop clearing the
canvas between frames. Instead of ctx.clearRect or fillRect(black) at full opacity, draw black at low alpha. Each old pixel decays toward black a
little per frame; new pixels arrive at full brightness. Older parts of the trace fade away
while the leading edge stays bright.
The math is straight exponential decay. If you blend a frame against a black overlay with
alpha α, the pixel value after n frames is v · (1 − α)^n. At α = 0.025, after 60 frames a pixel is at (0.975)^60 ≈ 0.22 of its initial brightness, so the trail is visible for about a
second before fading below perceptible. Crank α to 1 and you get a hard clear (no trail at
all). Crank it to 0 and pixels never fade (the screen burns in).
1// Instead of clearing the canvas, draw black at low alpha over it.2// Every old pixel decays exponentially: brightness *= (1 − fadeAlpha)3// per frame. Smaller alpha = longer phosphor persistence.4const fadeAlpha = 0.025 * TRAIL_LENGTH;5ctx.fillStyle = `rgba(10, 10, 10, ${fadeAlpha})`;6ctx.fillRect(0, 0, W, H);7 8// Then draw this frame's curve on top. Each frame's stroke is the9// brightest layer; older frames recede toward black.10drawCurve(a, b, delta); There's no real physics here: a real CRT phosphor has a non-exponential decay curve with a
fast initial component and a long tail. The exponential we use is close enough that nobody
notices. The visual signature lives in two things: that the trail fades smoothly to black, and
that the leading edge of the trace is always the brightest pixel on screen. Both come free
with one fillRect.
Velocity tells brightness
Watch a real oscilloscope. The trace is brighter where the curve loops back on itself and dimmer where it sweeps across long distances quickly. The reason is physical: the electron beam moves at constant angular speed but the trace it draws goes faster through some regions than others, and pixel brightness depends on how long the beam dwells per pixel. Slow regions accumulate more photons.
The shortcut: sample the segment speed periodically and scale the line width inversely. Where the curve moves slowly between consecutive points, the stroke is fat. Where it sprints, thin. One formula:
thickness = base · (0.6 + 1.2 / (1 + speed · 0.25))
Tuning is empirical. The 0.6 floor keeps fast regions from disappearing entirely. The 1.2 multiplier sets how much variation you get. The 0.25 scale on speed sets the speed at which the falloff kicks in. None of these are derivable; you find them by sliding numbers until the trace looks right.
1// Pre-compute all (x, y) into typed-array buffers, once per frame.2for (let i = 0; i <= N; i++) {3 const t = i * step;4 ptX[i] = cx + Math.sin(a * t + delta) * size;5 ptY[i] = cy + Math.sin(b * t) * size;6}7 8// Then stroke in batches of 20 points, with one thickness per batch9// sampled from the segment speed at the batch's midpoint.10const batch = 20;11for (let i = 0; i < N; i += batch) {12 const end = Math.min(i + batch, N);13 const mid = Math.min(i + (batch >> 1), N - 1);14 const dx = ptX[mid + 1] - ptX[mid];15 const dy = ptY[mid + 1] - ptY[mid];16 const speed = Math.sqrt(dx * dx + dy * dy);17 18 // The inverse-speed formula: thicker where the curve lingers.19 let mult = 1.0 / (1.0 + speed * 0.25);20 mult = 0.6 + mult * 1.2;21 ctx.lineWidth = BASE * mult;22 23 ctx.beginPath();24 ctx.moveTo(ptX[i], ptY[i]);25 for (let j = i + 1; j <= end; j++) ctx.lineTo(ptX[j], ptY[j]);26 ctx.stroke();27} With modulation off, the trace looks like a CSS border drawn around an SVG shape: technically correct, visually flat. With modulation on, the figure suddenly has weight. The same 3000 points, the same colors, the same phosphor fade. The only difference is that the renderer now respects how long the beam spent at each location.
Drifting through parameter space
A frozen Lissajous figure is a math demo. The full Analog Drift effect comes from slowly
animating (a, b, δ) through a sequence of curated waypoints, so the trace
morphs between known-nice figures over a span of tens of seconds. The phosphor persistence
makes the transitions visible: old waypoints are still glowing while the new one draws itself
on top.
Picking waypoints is curation, not math. Ten figures, hand-chosen: a circle, a figure-8, a trefoil, a few rose-like ones, then back to a diagonal line that almost dissolves to nothing before the loop restarts. Random parameter sweeps look chaotic; a curated tour reads as intentional.
The interpolation matters too. Linear interpolation between waypoints feels mechanical: it moves at the same speed through the whole transition, arriving at the new waypoint at the same velocity it left the old one. Ease-in-out cubic is what sells the analog feeling: slow at both ends of the transition, fastest in the middle. The same curve a mechanical control knob would make if you turned it smoothly without overshooting.
1// 10 waypoints in (a, b, δ) space. Each one is a closed or2// near-closed Lissajous figure chosen by eye.3const waypoints = [4 { a: 1, b: 1, delta: Math.PI / 2 }, // circle5 { a: 1, b: 2, delta: 0 }, // figure-86 { a: 2, b: 3, delta: Math.PI / 4 }, // trefoil7 { a: 3, b: 4, delta: Math.PI / 6 }, // rose-like8 // …six more9];10 11function easeCubic(x) {12 return x < 0.5 ? 4*x*x*x : 1 - Math.pow(-2*x + 2, 3) / 2;13}14 15function getParams(t) {16 const n = waypoints.length;17 const pos = (t % n + n) % n;18 const idx = Math.floor(pos);19 let frac = pos - idx;20 frac = easeCubic(frac); // <-- the analog feel21 const from = waypoints[idx];22 const to = waypoints[(idx + 1) % n];23 return {24 a: from.a + (to.a - from.a) * frac,25 b: from.b + (to.b - from.b) * frac,26 delta: from.delta + (to.delta - from.delta) * frac27 };28} Harmonic overtones
One more layer. Stack three additional Lissajous curves behind the main one, each at a
multiple of the base frequencies. The 2× harmonic (2a, 2b) is denser and tighter,
drawn at about 30% alpha. The 3× harmonic (3a, 2b) is denser still, dimmer. A
sub-harmonic (a/2, b/2) goes the other way: larger, slower, even dimmer, drifting
behind everything.
All four curves share the same drift waypoints, so they morph together. The harmonics multiply or halve the frequencies, but they're locked to the same δ progression. The visual effect is that the figure looks fuller without looking busy, the same way an audio signal with harmonic overtones sounds richer than a pure sine wave at the same fundamental.
1// Sub-harmonic (drawn first, behind everything else)2drawCurve(a * 0.5, b * 0.5, delta * 0.7 - time * 0.05, 0.42,3 'rgba(180, 130, 90, 1)', 0.18, 0.6);4 5// 3rd harmonic6drawCurve(a * 3, b * 2, delta * 1.5 + time * 0.08, 0.32,7 'rgba(200, 149, 108, 1)', 0.20, 0.7);8 9// 2nd harmonic10drawCurve(a * 2, b * 2, delta + time * 0.15, 0.34,11 'rgba(200, 149, 108, 1)', 0.28, 0.9);12 13// Main trace (drawn last, brightest, on top)14drawCurve(a, b, delta, 0.38, 'rgba(220, 180, 130, 1)', 0.85, 1.5); The harmonics aren't a single toggle in the production shader; each is configurable independently. Together they account for maybe 5% of the visible image but 30% of the impression that the signal is rich, not synthetic.
Everything together
Add a faint oscilloscope grid behind the trace (subtle, breathing). Add a Gaussian-bloom layer
under the main curve using shadowBlur for CRT phosphor glow. Add an offset cyan
shimmer trace at δ + 0.01 over the amber base for a hint of chromatic
aberration. Add 40 brightly-cored phosphor dots distributed along the curve, pulsing
independently of the trace. None of those is essential. All of them are easy.
Why this is fast
Four curves of 3000 points each, redrawn 60 times per second, is 720K points per second through Canvas 2D. None of it is intuitively cheap.
Pre-allocated typed arrays. Point coordinates go into two Float64Array(4001) buffers allocated once at startup. No per-frame garbage. The browser's GC never sees this
hot loop.
Batched strokes per thickness. Naive line-width-per-segment would call beginPath, stroke, and a state-change 3000 times per curve. Sampling
one thickness per 20-point batch is 150 strokes per curve, with the path itself still drawn
as one continuous moveTo/lineTo sequence per batch.
Bloom only where it pays. shadowBlur is one of Canvas 2D's most
expensive operations: it forces a software pass for the entire stroke. We apply it only to
the main trace and the cyan accent (the two layers the eye lingers on), and skip it entirely
on the three harmonics.
The whole shader holds 60fps on a 2019 laptop with the dev tools open. The bottleneck isn't math or memory; it's how many times we ask Canvas 2D to flush its state.
Where to go from here
Lissajous on Wikipedia covers the math thoroughly, including the closed-curve conditions and the rational/irrational distinction. MathWorld has a more compact treatment with the parametric forms.
If you want a deeper rabbit hole, the underlying technique generalizes to any 2D parametric
curve. Replace the sines with rose curves r = cos(k·θ), with epicycloids, with
audio waveforms sampled in real time. The phosphor-fade-plus-velocity-thickness rendering
layer is independent of what's being plotted. Feed it any function; it'll look like that
function as seen through an oscilloscope.
The whole analog-drift shader is 434 lines of HTML. Roughly half is the curve math and harmonics. A third is the rendering setup. The rest is parameter wiring and the bit of oscilloscope chrome that sells the illusion. Drop it on any page; it has no dependencies.