Article Aurora drift
An atmosphere, not a canvas
How a mod() and a stack of soft circles become an aurora, and why every cursor interaction in it is global, unbounded, or a modulation, never a spotlight.
9 min read · Part of Aurora drift, where the component itself is live
The big picture
An aurora is the hardest kind of thing to fake, because it has no edges. Real curtains of light are diffuse everywhere: no outline, no core, no point you could point at. This component takes that as an engineering constraint rather than a mood. The colour comes from a handful of soft circles tiled through space; the shape comes from warping the coordinate before the lookup; and every way the cursor touches the field is deliberately not local.
Aurora drift, the live component
Live, try itThree decisions carry the whole piece. The visual is one blob stack, tiled by mod(). The shape is two fBm samples displacing the lookup. And the interactivity obeys a rule most cursor effects ignore: if the visual is soft, the inputs have to be soft too.
One blob, tiled forever
Strip the motion away and the colour is almost embarrassingly simple: two to six circles, stacked vertically, each blended over the background by its Euclidean distance, softened by a smoothstep. On its own that renders one ribbon in the middle of the screen. The mod() is what makes it an atmosphere: fold every pixel of an infinite plane into one canonical cell, and the cell, blobs and all, repeats forever.
// One blob stack, tiled across infinite space. mod() folds every// pixel into a canonical cell centred on zero; the blob positions// hang below it, spaced by blobSpacing.vec2 op = pos - vec2(u_blobOffsetX, u_blobOffsetY);float sp = u_tileSpacing;op = mod(op - sp, vec2(sp * 2.0)) - sp;float totalRot = u_blobRotation + u_autoRotation * t;op = rot2d(op, -totalRot);op /= max(u_blobSize, 0.01);op *= vec2(1.0 / max(u_blobSpread, 0.01), 1.0); // anisotropy// …then, per blob: col = mix(colour_i, col,// smoothstep(0.0, bs, distance(op, vec2(0.0, y_i))));Two refinements turn circles into curtains. The rotation, a fixed base plus a slow auto-rotation, tilts the whole lattice so the ribbons run diagonal to the frame. And the spread is anisotropic: the x axis is divided by blobSpread while y is not, stretching each circle into a horizontal bar of light. That single asymmetry is most of what your eye reads as "aurora" rather than "dots".
One mod() draws an infinite field
InteractiveThe shear that makes it aurora
Untiled, unwrapped, the ribbons would sit there like a barcode. The shear that makes them live is domain warping, again in its two-sample form: one noise field displaces x, another displaces y, and the tile grid is read after the displacement, so the warp bends the seams themselves and the tiling never shows.
// Two uncorrelated fBm samples, one per axis. The seed drifts// along the noise's third axis at its own speed, so the field// morphs instead of scrolling.float d1 = fbm(vec3(pos * u_warpScale + 0.5, animSeed), oct);float d2 = fbm(vec3(pos * u_warpScale + 0.5 + 5.3, animSeed + 1.7), oct);pos += vec2(d1, d2) * u_warpStrength * warpBoost;// warpBoost is 1.0, except under Swell, where the cursor lifts it.The noise is 3D, and the third dimension is doing the work of time, but not as a scroll. The seed drifts along the noise's third axis at seedSpeed, which morphs the field in place: curtains churn and re-form without ever translating across the screen. That difference, morphing versus scrolling, is what separates weather from wallpaper.
Notice the warpBoost factor at the end of the displacement line, sitting at 1.0. It is a hand-off: the one place where the cursor is allowed to change the field's behaviour. Its story is the next section.
Five soft answers to a cursor
The obvious way to make a cursor affect a soft field is a radius: inside it, displace; outside, don't. An earlier iteration of this shader did exactly that, and the effect was ruinous in a specific way: the aurora warped in a visible circle that followed the cursor like a magnifying glass. The input had a harder edge than the thing it was touching.
Breeze and Parallax go global: no position dependence at all. Breeze accumulates pointer velocity into a decaying wind vector (the field remembers how you moved for a moment after you stop) and shifts every pixel by the same amount.
// Breeze (JS): velocity accumulates into a wind vector that// decays each frame; the wind remembers recent motion.windX = windX * decay + velocity.x * gain;windY = windY * decay + velocity.y * gain;// Breeze (GLSL): the same offset lands on EVERY pixel.pos += u_mouseWind * u_mouseStr;Tide is the unbounded falloff: 1 / (1 + r²·k) decays smoothly forever and never reaches zero. The subtle half of the idea is multiplying by the un-normalized direction vector, so the pull goes to zero exactly at the cursor; a normalized vector there would spin the field into a pinwheel at the one point you are looking at.
// Tide: an unbounded soft well. 1/(1 + r²·k) never reaches zero,// and multiplying by the UN-normalized toCursor makes the pull// vanish AT the cursor instead of pinwheeling there.float grav = 1.0 / (1.0 + mDist * mDist * 0.8);pos -= toCursor * grav * u_mouseStr * 0.7;Swell and Shimmer modulate a parameter instead of a position. Swell is the default and the subtlest: cursor proximity multiplies the warp amplitude by up to 2.5× through a broad Gaussian. Nothing is displaced, so nothing can have an edge; the aurora simply "breathes harder" where you are.
// Swell: proximity is a broad Gaussian on the warp AMPLITUDE.// No displacement: there is no circle because nothing moves.float sigma2 = max(u_mouseRadius * u_mouseRadius, 0.04);float proximity = exp(-mDist * mDist / sigma2);warpBoost = 1.0 + proximity * u_mouseStr * 2.5;Shimmer applies the same Gaussian to a global high-frequency noise layer: the sparkle pattern exists everywhere, and proximity decides how much of it is mixed in. Five modes, three strategies, one rule, and you can feel the rule by switching modes on the component's page while moving the cursor in slow circles.
The one local event
Hovering is ambient, so it stays ambient. Clicking is a deliberate act, and the shader answers in kind: the pulse is the only effect with a position and a boundary: a ring that expands from where you clicked, displacing the field as it passes.
// The click: the one deliberately local effect. A Gaussian// shell at an expanding radius, cubically faded.float ringR = u_pulseAge * u_pulseSpeed;float ringDelta = pDist - ringR;float ringMask = exp(-(ringDelta * ringDelta) / (u_pulseWidth * u_pulseWidth));pos += pdirN * ringMask * u_pulseStr;The engineering is edge-triggered on the JS side (a pulse starts on the pointer-down transition, not while the button is held), and its whole state is four uniforms: position, age, and a strength that the CPU multiplies by fade³ each frame until it retires. Because the ring displaces the lookupbefore the warp and the tiles, the wave fronts break across the curtains like a bow wave; that composure is the payoff of doing every effect in coordinate space.
Palettes and film
The palette is five colour dials (four curtains and a background), and eleven named presets from Boreal Green to Nebula Drift are just that palette, re-picked. A colour shift dial adds a slow hue rotation in YIQ space, so a preset can be set drifting through its own neighbourhood of moods without ever leaving them.
The finish is film grain, and its one interesting decision is temporal: the value noise is re-seeded in discrete steps of floor(t · grainSpeed), so each grain pattern holds for a frame count and then jumps. Continuous grain crawls like sand; stepped grain flickers like film. The jump is the look.
// Film grain that flickers instead of crawling: re-seed the// value noise in DISCRETE time steps.float tStep = floor(t * max(u_grainSpeed, 0.0));gp += vec2(tStep * 13.17, tStep * 7.91);col += valNoise(gp) * u_grainAmount;Steal the rule, not the shader
The shader is a good aurora, but the transferable part of this component is the discipline it imposes on input. Any time you hook a cursor into something diffuse (fog, smoke, water, light), the same three strategies apply: go global, use a falloff that never reaches zero, or modulate an amplitude instead of a position. The test is simple: if a viewer could trace the boundary of your cursor's influence, the input is harder than the visual, and one of them should give.
Every strategy here is a mode on the Aurora drift's page, next to the live preview and the full panel: warp, tiles, framing, all five cursor modes with their own strength and radius dials, and the palette presets. Tune the field to your mood and the build prompt carries your exact values to your stack.