Lightning
Branching bolts struck at irregular intervals on one canvas — fractal channels with a bright core, a stacked glow, a decaying afterimage and a flash that lifts the whole surface.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/lightning.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Lightning" component — branching bolts
struck at irregular intervals on one canvas, used as a hero / 404 / launch
backdrop. Its only dependency is a cn() class merger (clsx + tailwind-merge).
Contract
- export function Lightning(props): props extend React.ComponentProps<"div">
(rest props spread onto the root) plus:
- frequency?: number (default 0.7) — average strikes per second, clamped
0..8. 0 is a first-class mode, not a bug: nothing is ever scheduled and
requestAnimationFrame is never called.
- forks?: number (default 3) — branches off the trunk, clamped 0..6 and
rounded. Each may grow one sub-branch of its own.
- jitter?: number (default 1) — multiplier on the midpoint displacement,
clamped 0..3. 0 gives a straight discharge.
- decay?: number (default 0.5) — seconds the afterimage lingers, clamped
0.08..4.
- glow?: number (default 1) — width multiplier of the soft glow, clamped
0..3. 0 draws the bare channel.
- flash?: number (default 1) — strength of the whole-canvas flash, clamped
0..2. 0 leaves the surface alone.
- tone?: "foreground" | "primary" | "chart-1" | "chart-2" (default
"foreground") — which semantic token the bolt is painted with.
- seed?: number (default 3) — integer seed for the storm.
- children render above the canvas; className merges onto the root.
- "use client": canvas, rAF and observers.
- Clamp every numeric prop before use and treat non-finite values as the
default. A NaN frequency would schedule strikes at NaN seconds (never), a 0
decay would divide by zero in the brightness envelope, and an unbounded fork
count would overrun the channel pool.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that
is aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper
for children, so content always sits above the storm and the canvas can
never intercept a click. The component paints NO background of its own: the
surface belongs to the consumer.
- Bolt geometry, midpoint displacement: start with the two endpoints, then
repeatedly split every segment and push the new midpoint along the segment
NORMAL by a random amount that shrinks by 0.55 each pass. Displacing along
the normal (not along x) means the wander is always across the channel and
never stretches it. 6 passes for the trunk = 64 segments / 65 points; 4 for
a branch, 3 for a sub-branch. Expand the point list IN PLACE from the back
(point i moves to 2i, iterating i downward) so one scratch Float32Array pair
serves every channel and nothing is allocated mid-build.
- The trunk starts 6px ABOVE the top edge and ends 6px below the bottom one.
A bolt that begins exactly on an edge reads as a cropped stroke rather than
one passing through the frame.
- Branching: right after a channel is finished, capture `forks` anchors off
it — anchor COORDINATES plus a heading, never indices, because the next
build overwrites the shared scratch buffer. The heading is the parent's
local direction (from the two neighbouring points) rotated by
+/-(0.28..0.78 rad), so branches keep travelling downward instead of
doubling back up the frame. A branch's length is the span still AHEAD of its
anchor times 0.35..0.75, which is what makes branches near the top long and
branches near the bottom stubs. Push the anchors onto a fixed-size queue and
drain it; a depth-1 branch spawns a sub-branch 55% of the time. Hard caps:
16 channels per bolt, 4 concurrent bolts, both preallocated.
- Freeze each finished channel into a Path2D once, at strike time. A frame
then only re-strokes existing paths instead of replaying 65 lineTo calls per
channel per pass — the difference between allocating per strike (about once
a second) and allocating per frame (60 times a second), which is the failure
mode of every full-screen canvas background.
- Brightness envelope per bolt: full for the first 45ms (the return stroke,
about two frames), then a hard drop to 42% — the ionised afterimage — which
decays quadratically to EXACTLY 0 at the end of life, so a bolt dims out
instead of popping out of existence. Multiply by a per-bolt shimmer
0.8 + 0.2*cos(age * rate) with rate 30..56 rad/s. life is decay * 0.7..1.3,
floored above the hold window so the decay divisor stays positive.
- Ambient flash: sum env^2 * perBoltFlash across live bolts, clamp to 1, and
fill the WHOLE canvas with the ink at that alpha * 0.16 * flash BEFORE
stroking the bolts, so each bolt sits on top of the surface it just lifted.
- Schedule: an irregular gap of (1/frequency) * 0.45..1.75 seconds. With 34%
probability (max twice in a row) the next gap is instead 50..170ms and
re-uses the previous trunk position — real flashes are several strokes down
one channel a few tens of ms apart, and that stutter is most of what sells
the effect. Advance the schedule in a `while` loop, not an `if`, so a long
frame cannot leave it behind, with a guard capping it at the pool size.
- Determinism: every strike opens a stream keyed by (seed, strikeIndex) and
pulls values from an integer hash with an incrementing cursor. Math.random()
is never called — not during render (purity/SSR) and not in the loop
(screenshots must be reproducible). Same seed, same storm: same timings,
same silhouettes.
- Idle frames: when no bolt is alive, clear the canvas once and then do not
touch it at all until the next strike. At the default frequency that is most
of the frames, and it is the difference between a decoration and a tax.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas
is on screen AND document.visibilityState is "visible". dt is clamped to
1/30s so a backgrounded tab cannot fast-forward the storm on resume, and the
time base resets when the loop restarts.
- Surface-aware degradation: walk up from the canvas to the first ancestor
with an opaque background, resolve that colour through a 1x1 scratch canvas
(fillRect + getImageData — no hand parsing, so oklch() / color-mix() work)
and compute its relative luminance. Below 0.35 it is a night sky: full glow,
and the flash reads as a lift. Above it the ink is dark, so the same glow
reads far heavier — scale it to 0.62 and the flash to 0.4, where it becomes
a brief dimming and the bolt reads as an ink strike on paper. This follows
the SURFACE, not a theme class, so an inverted panel inside a light theme
still gets the night-sky treatment. Fall back to the `dark` class only if
getImageData is unavailable.
- Ink: the canvas carries the tone token as an inline `color`; the loop reads
getComputedStyle(canvas).color back and assigns the string straight to
fillStyle/strokeStyle, with every alpha on globalAlpha. Never hand-parse a
colour; passing the computed string through means any syntax the browser
understands works.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing. Try
observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
browsers that do not know that box throw a WebIDL TypeError from observe()
rather than ignoring it — and fall back to observe(canvas). The two scale
sources disagree in BOTH directions, so take max(devicePixelRatio, boxRatio)
capped at 2: emulated surfaces report a 1:1 device box while rendering at 2x,
and real 2x windows sometimes report devicePixelRatio 1 with a correct box.
Use the exact device box only when the two agree to within 0.01, purely to
absorb sub-pixel rounding at 1.25x/1.5x. Re-apply ctx.setTransform after
every resize (writing
canvas.width resets the context) and derive the scale from the actual
backing size. Paths are baked in CSS px, so a resize drops the live bolts
and strikes fresh — they live well under a second, and rescaling paths would
cost more than regenerating them.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
the ink and the surface, repaints the frozen frame when the loop is paused,
and schedules ONE more read about 400ms later — surfaces animated with
transition-colors report an intermediate colour for a few hundred ms, which
is long enough to latch the wrong night/ink decision.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
snapshot false, so it is hydration-safe) and keep it in the effect deps.
Under reduce, and equally under frequency=0, the loop never starts and one
frame is COMPOSED instead: strike #0 held at the return stroke plus strike
#1 already 40% decayed, so the still reads as a storm caught mid-flash
rather than as a blank box.
- Cleanup on unmount: cancelAnimationFrame, both observers, the
MutationObserver, the settle timeout and the visibilitychange listener.
Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--foreground) /
var(--primary) / var(--chart-1) / var(--chart-2) resolved through the
canvas's own computed style, so light/dark and any rebranded palette come
for free. Alpha lives in globalAlpha, never in the colour string.
- Glow without a blur filter: stroke each path four times — [7.5x width @ 5%
alpha], [3.6x @ 9%], [1.8x @ 16%], then the core at 92% — with round caps
and joins. Stacking strokes approximates a Gaussian falloff for a fraction
of what shadowBlur or filter: blur() costs on a full-screen canvas, and it
is the reason the effect stays cheap at high density.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes.
- Accessibility: the canvas is aria-hidden and pointer-events-none, pure
decoration; children stay fully interactive, selectable and above the storm.
The ambient flash is the one part of this component that can harm someone:
a full-surface flash faster than about 3 per second is a photosensitivity
risk, so pair a high frequency with a LOW flash (or flash=0), keep the peak
alpha in the 0.16 range it ships with, and remember that reduced motion
already removes the flashing entirely.
Customization levers
- Rate and drama: frequency is the headline knob — 0.3 is distant weather, 2.5+ is a
downpour, 0 is a single static bolt. RESTRIKE_CHANCE and MAX_RESTRIKES
control how often a strike stutters into a multi-stroke flash.
- Silhouette: forks (0 = a single channel, 6 = a river delta), jitter (the
displacement multiplier), ROUGHNESS 0.55 (how fast the wander shrinks per
pass — raise it toward 0.7 for a shaggier bolt), and the 0.28..0.78 rad
branch angle band (narrow it for a tree, widen it for a spider).
- Persistence: decay is how long the ionised channel lingers; AFTERIMAGE
(0.42) is how bright it is once the head has gone; HOLD (45ms) is how long
the return stroke stays at full brightness.
- Weight: glow scales the three glow passes; CORE_WIDTH is the trunk's core
width in CSS px; DEPTH_WIDTH / DEPTH_ALPHA (0.58 / 0.78 per level) decide
how much thinner and dimmer each branch generation is.
- Palette: tone picks the token. Add an entry pointing at any token —
var(--primary-foreground) is the right ink for an inverted panel,
var(--chart-4) for a branded storm.
- Cost: per frame it is (live bolts) x (channels) x 4 strokes — about 20 wide
strokes at the defaults, and zero on the frames between strikes. Drop the
first entry of the glow stack to make it 3, lower MAX_DPR to 1 to halve the
fill cost on retina, or cap MAX_BOLTS if you raise frequency a lot.
- Degradation: SURFACE_DARK_MAX_LUMA, LIGHT_GLOW_SCALE and LIGHT_FLASH_SCALE
define the light-surface look; replace the probe with a constant if you know
your surface will always be dark.Concepts
- Midpoint displacement — a channel is two endpoints subdivided six times, each new midpoint pushed along the segment normal by an amount that shrinks 0.55x per pass. Displacing along the normal keeps the wander across the channel, so the bolt never stretches or doubles back; the shrinking amplitude is what gives it big lateral swings with fine detail on top.
- Anchor capture, not index capture — branches are queued as coordinates plus a heading the instant their parent finishes, because the next channel overwrites the shared scratch buffer. Their length is the span still ahead of the anchor, which is why branches near the top run long and branches near the bottom are stubs.
- Return stroke, then afterimage — brightness holds at full for about two frames, drops hard to 42%, then decays to exactly zero at the end of
decay. The hard step is the flash; the tail is the ionised channel cooling; landing on exactly zero is what stops a bolt from popping out of existence. - Multi-stroke re-strike — a third of strikes are followed within 50–170ms by another down the same trunk. Real lightning is several strokes in one channel, and that stutter reads as "lightning" far more than any single silhouette does.
- Glow by stacking, not by blurring — the same
Path2Dis stroked wide-and-faint, then narrower-and-brighter, then as a core: four strokes that approximate a Gaussian falloff for a fraction ofshadowBlur. The stack is also where the surface probe applies its correction, since dark ink on a light page needs a much tighter halo than bright ink on a dark one. - Frames that cost nothing — geometry is baked into
Path2Dat strike time, frames with no live bolt never touch the canvas, and the loop stops outright when the element scrolls off screen or the tab hides. Underprefers-reduced-motion(orfrequency={0}) it never starts at all and paints one composed still instead.
Constellation
A drifting node network on one canvas — every pair closer than the link threshold is stroked, opacity falling off with distance, with optional pointer attraction.
Plasma
The demoscene plasma field on one canvas — sines summed over x, y and time, mapped through a cyclic token palette at low resolution and upscaled to fill the container.