ethereal-glow

Documentation

Travelling-light, black-hole and dithered glow effects for React — two CSS renderers and one pixelated-canvas renderer, driven by one shared ~60fps loop.

On this page

Getting started

Install the package. React ≥ 18 is a peer dependency.

npm i ethereal-glow

Or, if you use shadcn/ui, take the registry path instead — it drops an EtherealButton into components/ui/ that you own and can restyle, with ethereal-glow added as a dependency so fixes still arrive over npm rather than being stranded in your tree.

npx shadcn@latest add https://ethereal.ale.design/r/ethereal.json

Drop an effect inside any element that is a positioned, isolated host — position: relative plus isolation: isolate. Keep your own content on a higher stacking layer (relative z-10) so it sits above the glow.

import { Ethereal, EventHorizon } from 'ethereal-glow'

// Ethereal — a comet travels the border behind a spotlight mask
<button className="relative isolate ...">
  <span className="relative z-10">Get started</span>
  <Ethereal path="around" heads={2} spin="counter" />
</button>

// EventHorizon — a black-hole accretion disk
<button className="relative isolate ...">
  <span className="relative z-10">Enter</span>
  <EventHorizon />
</button>
Next.js App Router: all three components already ship the 'use client' directive, so you can import them straight into a Server Component. You only need your own 'use client' if the surrounding file also uses state or event handlers.

<Ethereal> API

A comet head travels along the element behind a spotlight mask: a lit stretch of the border ring, an interior color wash, thin light needles and a white-hot core. Every prop is an optional override of the exported ETHEREAL defaults.

PropTypeDefaultDescription
Colors, path & timing
colorsstring[]9-color rainbowPalette cycled across blobs, needles and cores. Any CSS-parseable color.
path'bottom' | 'around' | 'breathe' | 'static''bottom'bottom = light sweeps the bottom edge · around = full perimeter · breathe = stationary pulse · static = fixed bottom band with waveform needles.
durationnumber3.1Seconds for one full travel cycle (clamped to 0.1–600).
repeatDelaynumber0Dead time (s) after each cycle before the next begins — less frequent, not slower.
heads1 | 21One travelling comet head, or two.
spin'same' | 'counter''same'With heads = 2, the two heads co-rotate or counter-rotate.
travelEase'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'back' | 'bounce''linear'Easing applied to the head's progress along the path.
wandernumber0Slow sine warp of progress — the head hesitates and hurries (0 = steady).
breatheAmpnumber0.25Amplitude of the breathe-mode pulse; also drives subtle size pulsing.
Spot geometry & placement
place'internal' | 'external' | 'ext-border' | 'both''internal'Where the glow paints: inside the host, outside, only outside the border, or both. External placements can be clipped by ancestors (see caveats).
spotShape'adaptive' | 'round''round'adaptive stretches the spotlight along the travel direction; round is a chain of equal circles that each follow the path — never a stretched ellipse.
spotSamplesnumber0round only: circles in the chain (0 = auto from spotW/spotH ratio, capped at 9).
trailnumber1Comet-tail length multiplier for the spot chain (clamped to 0.2–4).
leadnumber0Leading trail — the chain extends AHEAD of the head too (0 = none, 1 = as long as the tail).
trailFadenumber0.45How strongly trail circles shrink and dim toward the tail end (0–1).
spotWnumber78Spotlight width in px (clamped to 1–4000).
spotHnumber60Height of the light in px (clamped to 1–4000). Scales the glow's paint — blobs, needles, edge-relight bands — as well as the reveal window.
heightMinnumber0.8Lower bound of the height breathing, ×spotH (travel paths; breathe keeps breatheAmp).
heightMaxnumber1.3Upper bound of the height breathing, ×spotH — raise it to let the light surge tall.
spotBlurnumber0Blur (px) of the masked-in spot content, softening the mask cutoff.
spotOffsetnumber0Pushes the spotlight anchor outward past the border along the head's normal (px). Ignored in breathe.
strokeWidthnumber1Thickness (px) of the lit border ring.
blendSoftnessnumber0.5Softness of gradient stops across the spot and needles (0–1).
revealnumber1Scales the bloom spotlight mask (internal and external) — smaller = tighter reveal.
hotspotsnumber1Extra white-hot cores fanned along the path, each walking it individually.
hotSpreadnumber22Spacing (px) between fanned hotspot cores.
Needles
needlesnumber7Count of thin light needles emitted along the edge.
needleHeightnumber1Length multiplier for the needles.
needleJitterbooleanfalsebreathe only: needles wobble and reshuffle onto new perimeter spots each cycle.
Bloom & opacities
glowBlurnumber8Gaussian blur (px) of the bloom / needle halo layer.
strokeOpacitynumber1.14Opacity multiplier of the lit border ring.
innerOpacitynumber0.7Opacity multiplier of the interior color wash (internal / both).
bloomOpacitynumber0.8Opacity multiplier of the needle bloom layer.
strengthnumber1Master opacity multiplier over all layers.
saturationnumber1saturate() multiplier applied to every layer.
brightnessnumber1brightness() multiplier applied to every layer.
hueRangenumber13Degrees of hue oscillation over time (0 = fixed hue; breathe does a full-circle rotation).
gamut'srgb' | 'p3''srgb'Color space for emitted gradients — p3 uses color(display-p3 …).
Hover
hover'none' | 'boost' | 'speed' | 'boost-speed' | 'reveal''none'Pointer response: none · boost (brighter + larger) · speed (faster travel) · boost-speed (both at once) · reveal (fades in from hidden on hover).
hoverAmountnumber1Strength of the hover response.
hoverEasenumber8Smoothing rate of the hover transition (higher = snappier).
Misc & theming
flickernumber0Candle-like irregular intensity jitter (0 = steady).
pulseMinnumber0.8Lower bound of the size / intensity pulse oscillators.
pulseMaxnumber1.4Upper bound of the size / intensity pulse oscillators.

States

<Ethereal> and <EtherealWrap> accept a state prop — a named partial config merged over your base props. Change it and the rebuilt layers cross-fade. The two built-ins are exported as ETHEREAL_STATES.

PropTypeDefaultDescription
statestring | null'idle'Named state to apply: a built-in ('idle' | 'thinking') or any key of `states`. Unknown names warn once and fall back to the base config; `null` suppresses state resolution entirely.
statesRecord<string, StateConfig<Cfg>>Custom / overriding state configs, merged over the built-ins per key.
themesPartial<Record<'light' | 'dark', Partial<Cfg>>>Per-theme base config, merged over your flat props and UNDER any named state — so a state can still override it. Note the shape: a themes branch is flat, unlike a states branch, so `themes.light.base.duration` is a silent no-op. It is the only per-theme mechanism on all three effects — a glow that should be dimmer on white is `themes={{ light: { strength: 0.6 } }}`.
transitionMsnumber320Cross-fade duration (ms) when the config or state changes; 0 disables the cross-fade.
whileHoverPartial<Cfg>Merged while the host is hovered (mouse only), with or without a named state. Theme-independent — for a light/dark split use a state's light.whileHover instead.
whilePressedPartial<Cfg>Merged while pressed (pointer or keyboard). Layers over whileHover, never directly over base — a real press is hovered and pressed at once. A quick click pulses for ~0.6s.
theme'light' | 'dark'autoPins the theme and skips detection entirely. Omit it and the effect resolves: host data-theme → host .dark/.light class → the same two checks on every ancestor → prefers-color-scheme. Tailwind's class strategy, shadcn/ui and next-themes all work with no configuration.
themeDetector(host: HTMLElement | null) => 'light' | 'dark'Replaces the whole resolution chain — use it when the theme lives somewhere the DOM does not show it (React context, a cookie, a custom attribute). Like `theme`, it opts out of watching: you own re-rendering when the value changes.

Light and dark can also want different values, not just a dimmer version of the same glow — branch the base config with themes. It sits below states on purpose: a state is the more specific thing, so a named state can still override the theme baseline. The full merge order, lowest to highest:

LayerSource
1exported defaults (ETHEREAL, EVENT_HORIZON, ETHEREAL_DITHER)
2your flat props
3themes[resolvedTheme]
4the named state's theme branch, then its hover/press slots
5the flat whileHover / whilePressed props

themes is the only per-theme mechanism — all three effects take it, and it is the whole light-mode story for each of them. “Same config, just dimmer on white” is themes={{ light: { strength: 0.6 } }}.

import { Ethereal, ETHEREAL_STATES } from 'ethereal-glow'

// Switch named states — the rebuilt layers cross-fade over transitionMs.
// Built-ins: 'idle' | 'thinking'.
<button className="relative isolate ...">
  <span className="relative z-10">Send</span>
  <Ethereal state={status} />
</button>
// A state is keyed by VARIANT, not by config key directly:
//   light / dark        the theme branch, picked by the resolved theme
//   .base               that theme's config for the state
//   .whileHover         merged on top while hovered (mouse only)
//   .whilePressed       merged on top while pressed — pointer OR keyboard,
//                       held ~600ms so quick clicks stay visible
// Theme owns interaction, so light and dark can carry different hover treatments.
// Later variants win, so whilePressed can override whileHover.
// Custom states merge over the built-ins per variant; unknown names warn
// once and fall back to the base config.
<Ethereal
  state="sending"
  states={{
    sending: {
      light: {
        base: { colors: ['#3b82f6'], duration: 1.2 },
        whileHover: { strength: 1.4 },
      },
      dark: {
        base: { colors: ['#7ab8ff'], duration: 1.2 },
        whileHover: { strength: 1.8 },
      },
    },
  }}
  transitionMs={200} // 0 disables the cross-fade
/>

Tuning this in the playground

The playground edits the same cascade this page describes, one cell at a time. The Base row is the flat config every state inherits; each named state below it is a states entry. Inside a row, the two pill strips pick which cell you are editing: theme, then interaction slot.

On the Base row the theme pills read base and dark ↑ rather than light/dark, because the base config is not symmetric: base edits the shared flat props, and dark ↑ writes a themes.dark override on top — the same shape as Tailwind’s base styles plus dark: variants. On a named state the pills are plain light / dark, because a state really does have two symmetric branches.

A green dot on a pill means that cell holds overrides; an amber dot beside a control means this cell sets it — click it to clear. Whichever control section you have expanded stays expanded as you switch theme, slot or state, so you can compare one group of values across cells without losing your place.

<EventHorizon> API

A black-hole accretion disk: a white-hot head orbits, trailing a doppler-tinted plasma stream, wrapped in a graduated gravitational lens, a living photon rim and a lensed halo. Overrides the exported EVENT_HORIZON defaults.

PropTypeDefaultDescription
Colors & timing
colorsstring[]['#ffb46b', '#ff8a3d', '#b58cff']Doppler palette cycled down the accretion tail.
durationnumber6Seconds for one orbit (clamped to 0.5–600).
repeatDelaynumber0Dead time (s) after each orbit before the next begins — less frequent, not slower.
dir1 | -11Orbit direction.
Disk geometry
ringnumber2Accretion-ring thickness (px).
tailnumber1.2Tail length multiplier.
nodesnumber9Tail micro-spot pairs — stream density (chain capped at 32 spots).
nodenumber1Node size multiplier.
shimmernumber0.45How much the tail surges and pulses.
shape'adaptive' | 'round''adaptive'round collapses oriented ellipses to fixed circles — the silhouette never swells or squashes through corners.
cornernumber0.3Superellipse corner exponent for the orbit path (lower = squarer).
Lens, halo & shadow
lensnumber4Graduated gravitational-lens backdrop-blur strength (0 = off).
halonumber0.9Halo opacity.
blurnumber14Halo gaussian blur (px).
distnumber0Halo distance off the border (px).
shadownumber0.35Center vignette depth.
Hover
hover'none' | 'boost' | 'speed' | 'reveal''boost'Pointer response — same modes as Ethereal.
hoverAmountnumber1.2Strength of the hover response.
hoverEasenumber8Smoothing rate of the hover transition.

EVENT_HORIZON_PRESETS

Five ready-made configs, spread straight onto the component.

Gargantua
Blue giant
Ember disk
Violet quasar
Neutron
import { EventHorizon, EVENT_HORIZON_PRESETS } from 'ethereal-glow'

<EventHorizon {...EVENT_HORIZON_PRESETS['Violet quasar']} />

State, theme & interaction props

Identical to Ethereal's, with EventHorizonCfg as Cfgstate, states, themes, whileHover, whilePressed and the theme props all resolve through the same shared implementation. Built-in states: EVENT_HORIZON_STATES.

PropTypeDefaultDescription
statestring | null'idle'Named state to apply: a built-in ('idle' | 'thinking') or any key of `states`. Unknown names warn once and fall back to the base config; `null` suppresses state resolution entirely.
statesRecord<string, StateConfig<Cfg>>Custom / overriding state configs, merged over the built-ins per key.
themesPartial<Record<'light' | 'dark', Partial<Cfg>>>Per-theme base config, merged over your flat props and UNDER any named state — so a state can still override it. Note the shape: a themes branch is flat, unlike a states branch, so `themes.light.base.duration` is a silent no-op. It is the only per-theme mechanism on all three effects — a glow that should be dimmer on white is `themes={{ light: { strength: 0.6 } }}`.
transitionMsnumber320Cross-fade duration (ms) when the config or state changes; 0 disables the cross-fade.
whileHoverPartial<Cfg>Merged while the host is hovered (mouse only), with or without a named state. Theme-independent — for a light/dark split use a state's light.whileHover instead.
whilePressedPartial<Cfg>Merged while pressed (pointer or keyboard). Layers over whileHover, never directly over base — a real press is hovered and pressed at once. A quick click pulses for ~0.6s.
theme'light' | 'dark'autoPins the theme and skips detection entirely. Omit it and the effect resolves: host data-theme → host .dark/.light class → the same two checks on every ancestor → prefers-color-scheme. Tailwind's class strategy, shadcn/ui and next-themes all work with no configuration.
themeDetector(host: HTMLElement | null) => 'light' | 'dark'Replaces the whole resolution chain — use it when the theme lives somewhere the DOM does not show it (React context, a cookie, a custom attribute). Like `theme`, it opts out of watching: you own re-rendering when the value changes.

<EtherealDither> API

The same travelling comet, rendered as ordered-dithered blocks on a canvas instead of CSS gradients — a Bayer-quantized palette that reads as pixel art. It is the one effect that repaints every tick rather than writing CSS variables, so consider setTickRate(30) when you use it. Overrides the exported ETHEREAL_DITHER defaults.

PropTypeDefaultDescription
Colors, path & timing
colorsstring[]['#4285f4', '#9b72cb', '#d96570', '#9b72cb']Palette cycled through the quantized blocks.
path'around' | 'bottom''around'Full perimeter, or a bottom-edge sweep.
place'both' | 'internal' | 'external''both'Which side of the border the blocks paint on.
durationnumber7Seconds for one full travel cycle (clamped to 0.1–600).
heads1 | 21One travelling head, or two.
spin'same' | 'counter''same'With heads = 2, the two heads co-rotate or counter-rotate.
travelEase'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'back' | 'bounce''linear'Easing applied to the head's progress along the path.
repeatDelaynumber0Seconds the head rests at the end of the path before the next cycle.
wandernumber0Slow sine warp of progress — the head hesitates and hurries.
Dither grid
blocknumber2Size of one block cell in px (clamped to 2–64) — the pixel size of the whole effect.
levelsnumber4Intensity quantization steps, 2–16. Fewer = chunkier, more posterized.
reachnumber75Glow radius around the head (px, 8–2000).
bandnumber6Thickness of the border band the glow hugs (px, 2–400).
bleednumber0How far the grid extends past the host (px). 0 = auto, wide enough for the edge band's tail to fade out.
cornernumber0.3Superellipse corner exponent for the path (lower = squarer).
Intensity & color
strengthnumber2Master intensity multiplier (0–4).
saturationnumber1.6Saturation multiplier applied to the palette.
brightnessnumber1Brightness multiplier applied to the palette.
hueRangenumber6Degrees of hue oscillation over time (0 = fixed hue).
flickernumber0Candle-like irregular intensity jitter (0 = steady).
pulseMinnumber0.9Lower bound of the reach pulse.
pulseMaxnumber1.15Upper bound of the reach pulse.
hotspotsnumber1Extra white-hot cores fanned along the path (1–8).
hotSpreadnumber22Spacing (px) between fanned cores.
Hover
hover'none' | 'boost''boost'Pointer response — boost brightens and enlarges the glow.
hoverAmountnumber1Strength of the hover response.

State, theme & interaction props

Identical to Ethereal's, with EtherealDitherCfg as Cfg. Built-in states: ETHEREAL_DITHER_STATES.

PropTypeDefaultDescription
statestring | null'idle'Named state to apply: a built-in ('idle' | 'thinking') or any key of `states`. Unknown names warn once and fall back to the base config; `null` suppresses state resolution entirely.
statesRecord<string, StateConfig<Cfg>>Custom / overriding state configs, merged over the built-ins per key.
themesPartial<Record<'light' | 'dark', Partial<Cfg>>>Per-theme base config, merged over your flat props and UNDER any named state — so a state can still override it. Note the shape: a themes branch is flat, unlike a states branch, so `themes.light.base.duration` is a silent no-op. It is the only per-theme mechanism on all three effects — a glow that should be dimmer on white is `themes={{ light: { strength: 0.6 } }}`.
transitionMsnumber320Cross-fade duration (ms) when the config or state changes; 0 disables the cross-fade.
whileHoverPartial<Cfg>Merged while the host is hovered (mouse only), with or without a named state. Theme-independent — for a light/dark split use a state's light.whileHover instead.
whilePressedPartial<Cfg>Merged while pressed (pointer or keyboard). Layers over whileHover, never directly over base — a real press is hovered and pressed at once. A quick click pulses for ~0.6s.
theme'light' | 'dark'autoPins the theme and skips detection entirely. Omit it and the effect resolves: host data-theme → host .dark/.light class → the same two checks on every ancestor → prefers-color-scheme. Tailwind's class strategy, shadcn/ui and next-themes all work with no configuration.
themeDetector(host: HTMLElement | null) => 'light' | 'dark'Replaces the whole resolution chain — use it when the theme lives somewhere the DOM does not show it (React context, a cookie, a custom attribute). Like `theme`, it opts out of watching: you own re-rendering when the value changes.

Wrap components

<EtherealWrap>, <EventHorizonWrap> and <EtherealDitherWrap> render the positioned, isolated host span for you and place the effect inside it, with your children on a raised layer. Use them when you can't edit the child — third-party components, or replaced elements like <input> and <textarea>, which can't contain the effect span at all.

They accept every config prop, plus className and style on the wrapper. The glow follows the wrapper's border radius, so match it to the child (e.g. give children rounded-[inherit]).

import { EtherealWrap, EventHorizonWrap, EtherealDitherWrap } from 'ethereal-glow'

// Replaced elements (inputs, textareas) can't contain the effect span —
// wrap them instead. The wrapper span becomes the positioned, isolated host.
<EtherealWrap path="static">
  <SearchInput />
</EtherealWrap>

// EventHorizonWrap and EtherealDitherWrap provide the same host contract.

Behavior & performance

  • One shared ticker. A single requestAnimationFrame loop, targeting ~60fps — one tick per frame on a 60Hz display; higher-refresh displays are gated down to the target — drives every instance of all three effects; it stops entirely when the last instance unmounts. Off-screen instances pause via an IntersectionObserver (160px margin), and the loop clamps dt after background-tab pauses so clocks never jump.
  • Tunable frame rate. setTickRate(fps) changes the shared loop’s target — 0 ticks at the display’s native refresh rate, 30 halves the paint cost for hero-size effects. Animation speed is unaffected either way, since every effect integrates against wall-clock dt rather than counting frames.
  • No layout thrash. Layout metrics are cached by a ResizeObserver and refreshed only on real size changes — the per-frame code only writes CSS custom properties, never reads offsetWidth/offsetHeight.
  • Reduced motion. Under prefers-reduced-motion: reduce a single static glow frame renders with no animation loop.
  • Host requirements. The parent must be position: relative + isolation: isolate. A position: static host is warned about in the console (the glow would anchor to the wrong ancestor).
  • One effect per host. Mounting two effects (or two of the same) on the same element makes them fight over the shared CSS variables; the second logs a console warning. Give each effect its own host.
  • State transitions. Re-render with a different config and the rebuilt layers cross-fade in over ~320ms — e.g. idle → thinking on a chat composer.
  • Theming. The effect honors html[data-theme], .light/.dark classes, or the OS scheme, so Tailwind's class strategy, shadcn/ui and next-themes all work unconfigured; theme pins it and themeDetector replaces the chain. One document-wide observer and one matchMedia listener serve every mounted instance, so a theme toggle updates the glow live no matter how many are on the page (use themes.light to tune any of the three effects for light backgrounds).
/* Every effect needs a positioned, isolated host. Tailwind: */
<button className="relative isolate ..."> … </button>

/* Plain CSS: */
.host { position: relative; isolation: isolate; }
Clipping caveat. With place: "external" / "ext-border" / "both" (and Event Horizon's halo always) the glow paints outside the host, so any ancestor that clips will cut it off: overflow hidden/auto/scroll/clip, contain: paint, clip-path, or a transformed ancestor with overflow. Pad the nearest scroll container by roughly glowBlur × 2 + 30px, lift the element out of the clipping wrapper, or use place: "internal". Note that internal placement draws inside the element and barely reads on a solid bright fill — use external placement there, or keep ethereal elements dark/outlined.