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-glowOr, 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.jsonDrop 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>'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.
| Prop | Type | Default | Description |
|---|---|---|---|
| Colors, path & timing | |||
| colors | string[] | 9-color rainbow | Palette 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. |
| duration | number | 3.1 | Seconds for one full travel cycle (clamped to 0.1–600). |
| repeatDelay | number | 0 | Dead time (s) after each cycle before the next begins — less frequent, not slower. |
| heads | 1 | 2 | 1 | One 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. |
| wander | number | 0 | Slow sine warp of progress — the head hesitates and hurries (0 = steady). |
| breatheAmp | number | 0.25 | Amplitude 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. |
| spotSamples | number | 0 | round only: circles in the chain (0 = auto from spotW/spotH ratio, capped at 9). |
| trail | number | 1 | Comet-tail length multiplier for the spot chain (clamped to 0.2–4). |
| lead | number | 0 | Leading trail — the chain extends AHEAD of the head too (0 = none, 1 = as long as the tail). |
| trailFade | number | 0.45 | How strongly trail circles shrink and dim toward the tail end (0–1). |
| spotW | number | 78 | Spotlight width in px (clamped to 1–4000). |
| spotH | number | 60 | Height 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. |
| heightMin | number | 0.8 | Lower bound of the height breathing, ×spotH (travel paths; breathe keeps breatheAmp). |
| heightMax | number | 1.3 | Upper bound of the height breathing, ×spotH — raise it to let the light surge tall. |
| spotBlur | number | 0 | Blur (px) of the masked-in spot content, softening the mask cutoff. |
| spotOffset | number | 0 | Pushes the spotlight anchor outward past the border along the head's normal (px). Ignored in breathe. |
| strokeWidth | number | 1 | Thickness (px) of the lit border ring. |
| blendSoftness | number | 0.5 | Softness of gradient stops across the spot and needles (0–1). |
| reveal | number | 1 | Scales the bloom spotlight mask (internal and external) — smaller = tighter reveal. |
| hotspots | number | 1 | Extra white-hot cores fanned along the path, each walking it individually. |
| hotSpread | number | 22 | Spacing (px) between fanned hotspot cores. |
| Needles | |||
| needles | number | 7 | Count of thin light needles emitted along the edge. |
| needleHeight | number | 1 | Length multiplier for the needles. |
| needleJitter | boolean | false | breathe only: needles wobble and reshuffle onto new perimeter spots each cycle. |
| Bloom & opacities | |||
| glowBlur | number | 8 | Gaussian blur (px) of the bloom / needle halo layer. |
| strokeOpacity | number | 1.14 | Opacity multiplier of the lit border ring. |
| innerOpacity | number | 0.7 | Opacity multiplier of the interior color wash (internal / both). |
| bloomOpacity | number | 0.8 | Opacity multiplier of the needle bloom layer. |
| strength | number | 1 | Master opacity multiplier over all layers. |
| saturation | number | 1 | saturate() multiplier applied to every layer. |
| brightness | number | 1 | brightness() multiplier applied to every layer. |
| hueRange | number | 13 | Degrees 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). |
| hoverAmount | number | 1 | Strength of the hover response. |
| hoverEase | number | 8 | Smoothing rate of the hover transition (higher = snappier). |
| Misc & theming | |||
| flicker | number | 0 | Candle-like irregular intensity jitter (0 = steady). |
| pulseMin | number | 0.8 | Lower bound of the size / intensity pulse oscillators. |
| pulseMax | number | 1.4 | Upper 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| state | string | 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. |
| states | Record<string, StateConfig<Cfg>> | — | Custom / overriding state configs, merged over the built-ins per key. |
| themes | Partial<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 } }}`. |
| transitionMs | number | 320 | Cross-fade duration (ms) when the config or state changes; 0 disables the cross-fade. |
| whileHover | Partial<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. |
| whilePressed | Partial<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' | auto | Pins 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:
| Layer | Source |
|---|---|
| 1 | exported defaults (ETHEREAL, EVENT_HORIZON, ETHEREAL_DITHER) |
| 2 | your flat props |
| 3 | themes[resolvedTheme] |
| 4 | the named state's theme branch, then its hover/press slots |
| 5 | the 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| Colors & timing | |||
| colors | string[] | ['#ffb46b', '#ff8a3d', '#b58cff'] | Doppler palette cycled down the accretion tail. |
| duration | number | 6 | Seconds for one orbit (clamped to 0.5–600). |
| repeatDelay | number | 0 | Dead time (s) after each orbit before the next begins — less frequent, not slower. |
| dir | 1 | -1 | 1 | Orbit direction. |
| Disk geometry | |||
| ring | number | 2 | Accretion-ring thickness (px). |
| tail | number | 1.2 | Tail length multiplier. |
| nodes | number | 9 | Tail micro-spot pairs — stream density (chain capped at 32 spots). |
| node | number | 1 | Node size multiplier. |
| shimmer | number | 0.45 | How 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. |
| corner | number | 0.3 | Superellipse corner exponent for the orbit path (lower = squarer). |
| Lens, halo & shadow | |||
| lens | number | 4 | Graduated gravitational-lens backdrop-blur strength (0 = off). |
| halo | number | 0.9 | Halo opacity. |
| blur | number | 14 | Halo gaussian blur (px). |
| dist | number | 0 | Halo distance off the border (px). |
| shadow | number | 0.35 | Center vignette depth. |
| Hover | |||
| hover | 'none' | 'boost' | 'speed' | 'reveal' | 'boost' | Pointer response — same modes as Ethereal. |
| hoverAmount | number | 1.2 | Strength of the hover response. |
| hoverEase | number | 8 | Smoothing rate of the hover transition. |
EVENT_HORIZON_PRESETS
Five ready-made configs, spread straight onto the component.
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 Cfg — state, states, themes, whileHover, whilePressed and the theme props all resolve through the same shared implementation. Built-in states: EVENT_HORIZON_STATES.
| Prop | Type | Default | Description |
|---|---|---|---|
| state | string | 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. |
| states | Record<string, StateConfig<Cfg>> | — | Custom / overriding state configs, merged over the built-ins per key. |
| themes | Partial<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 } }}`. |
| transitionMs | number | 320 | Cross-fade duration (ms) when the config or state changes; 0 disables the cross-fade. |
| whileHover | Partial<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. |
| whilePressed | Partial<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' | auto | Pins 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| Colors, path & timing | |||
| colors | string[] | ['#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. |
| duration | number | 7 | Seconds for one full travel cycle (clamped to 0.1–600). |
| heads | 1 | 2 | 1 | One 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. |
| repeatDelay | number | 0 | Seconds the head rests at the end of the path before the next cycle. |
| wander | number | 0 | Slow sine warp of progress — the head hesitates and hurries. |
| Dither grid | |||
| block | number | 2 | Size of one block cell in px (clamped to 2–64) — the pixel size of the whole effect. |
| levels | number | 4 | Intensity quantization steps, 2–16. Fewer = chunkier, more posterized. |
| reach | number | 75 | Glow radius around the head (px, 8–2000). |
| band | number | 6 | Thickness of the border band the glow hugs (px, 2–400). |
| bleed | number | 0 | How far the grid extends past the host (px). 0 = auto, wide enough for the edge band's tail to fade out. |
| corner | number | 0.3 | Superellipse corner exponent for the path (lower = squarer). |
| Intensity & color | |||
| strength | number | 2 | Master intensity multiplier (0–4). |
| saturation | number | 1.6 | Saturation multiplier applied to the palette. |
| brightness | number | 1 | Brightness multiplier applied to the palette. |
| hueRange | number | 6 | Degrees of hue oscillation over time (0 = fixed hue). |
| flicker | number | 0 | Candle-like irregular intensity jitter (0 = steady). |
| pulseMin | number | 0.9 | Lower bound of the reach pulse. |
| pulseMax | number | 1.15 | Upper bound of the reach pulse. |
| hotspots | number | 1 | Extra white-hot cores fanned along the path (1–8). |
| hotSpread | number | 22 | Spacing (px) between fanned cores. |
| Hover | |||
| hover | 'none' | 'boost' | 'boost' | Pointer response — boost brightens and enlarges the glow. |
| hoverAmount | number | 1 | Strength of the hover response. |
State, theme & interaction props
Identical to Ethereal's, with EtherealDitherCfg as Cfg. Built-in states: ETHEREAL_DITHER_STATES.
| Prop | Type | Default | Description |
|---|---|---|---|
| state | string | 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. |
| states | Record<string, StateConfig<Cfg>> | — | Custom / overriding state configs, merged over the built-ins per key. |
| themes | Partial<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 } }}`. |
| transitionMs | number | 320 | Cross-fade duration (ms) when the config or state changes; 0 disables the cross-fade. |
| whileHover | Partial<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. |
| whilePressed | Partial<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' | auto | Pins 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
requestAnimationFrameloop, 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 clampsdtafter background-tab pauses so clocks never jump. - Tunable frame rate.
setTickRate(fps)changes the shared loop’s target —0ticks at the display’s native refresh rate,30halves the paint cost for hero-size effects. Animation speed is unaffected either way, since every effect integrates against wall-clockdtrather 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: reducea single static glow frame renders with no animation loop. - Host requirements. The parent must be
position: relative+isolation: isolate. Aposition: statichost 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/.darkclasses, or the OS scheme, so Tailwind's class strategy, shadcn/ui and next-themes all work unconfigured;themepins it andthemeDetectorreplaces the chain. One document-wide observer and onematchMedialistener serve every mounted instance, so a theme toggle updates the glow live no matter how many are on the page (usethemes.lightto 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; }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.