Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Which semantic token the channel, its glow and the ambient flash are painted with. */
export type LightningTone = "foreground" | "primary" | "chart-1" | "chart-2"

/**
 * Bolt ink per tone — semantic tokens only. The value is written onto the
 * canvas as an inline `color`, then the *computed* string is read back and
 * handed to the 2D context verbatim. Any syntax the browser resolves
 * (oklch(), color-mix(), a brand colour the consumer put behind the token)
 * works, and nothing here ever parses or hard-codes a colour.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/lightning.json

Prompt

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 Path2D is stroked wide-and-faint, then narrower-and-brighter, then as a core: four strokes that approximate a Gaussian falloff for a fraction of shadowBlur. 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 Path2D at 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. Under prefers-reduced-motion (or frequency={0}) it never starts at all and paints one composed still instead.

On This Page