VisorVisor
ComponentsVisual Elements

HeroGlow

Breathing radial glow band for hero media. Color is driven by a live CSS custom property so the caller can rewrite it every rAF frame without triggering a React re-render.

Preview

Hero content

Color Contract

HeroGlow is deliberately colorless by default. Color flows through the --glow-color CSS custom property. The component uses color-mix(in srgb, var(--glow-color) 16%, transparent) so only the hue is needed — opacity is baked into the gradient stop.

{/* Via prop */}
<HeroGlow glowColor="oklch(70% 0.3 145)" />

{/* Via ancestor CSS variable */}
<div style={{ '--glow-color': 'var(--color-acid)' }}>
  <HeroGlow />
</div>

Live rAF Updates

Because color flows through a CSS custom property, the consumer can rewrite --glow-color every animation frame without triggering a React re-render:

const glowRef = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
  let frame: number;
  const tick = () => {
    glowRef.current?.style.setProperty('--glow-color', computeArtistColor());
    frame = requestAnimationFrame(tick);
  };
  frame = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(frame);
}, []);

<div style={{ position: 'relative' }}>
  <HeroGlow ref={glowRef} />
  <img src="hero.jpg" alt="Hero" />
</div>

Animation

The breathing animation runs at a 7-second ease-in-out cycle:

  • Rest / peak: opacity 0.751, scale 11.03
  • Duration: 7s (tuned to a typical carousel dwell rhythm)
  • Easing: ease-in-out for a natural in-and-out breath

The animation is pure CSS (@keyframes hero-glow-breathe) — no JavaScript animation loop is needed.

Reduced Motion

When prefers-reduced-motion: reduce is active, the breathing animation is disabled. The glow appears at its rest-state opacity (0.75) and scale (1) — still visible, just not animated.

@media (prefers-reduced-motion: reduce) {
  .root {
    animation: none;
    opacity: 0.75;
    transform: scale(1);
  }
}

Layout Notes

HeroGlow is position: absolute with a negative inset:

inset: -6% -8% -10%;

This causes the glow to bleed slightly outside the parent's box edges, which is intentional — the glow band should feel larger than the hero container. The parent must be position: relative.

pointer-events: none and aria-hidden="true" are always applied — the element is purely decorative.

Installation

npx visor add hero-glow

Usage

import { HeroGlow } from '@/components/visual/hero-glow/hero-glow';

{/* Static color */}
<div style={{ position: 'relative' }}>
  <HeroGlow glowColor="#7c3aed" />
  <img src="hero.jpg" alt="Hero" />
</div>

{/* Color from ancestor variable */}
<section style={{ position: 'relative', '--glow-color': 'var(--brand-accent)' }}>
  <HeroGlow />
  <img src="hero.jpg" alt="Hero" />
</section>

API Reference

No props data available for “hero-glow”.

Accessibility

  • aria-hidden="true" is always applied — the glow is decorative and invisible to assistive technology.
  • pointer-events: none is always applied — the glow never captures clicks, focus, or hover events.
  • prefers-reduced-motion: reduce disables the breathing animation while keeping the glow visible.

When to Use

  • Behind hero images or carousels to cast a color-keyed ambient glow
  • When the glow color must track a live value (e.g. current artist color, active palette swatch)
  • Marketing hero sections that need atmospheric depth without a heavy WebGL shader

When Not to Use

  • When the parent is not position: relative — the glow will be clipped or invisible
  • As a layout element — HeroGlow is purely decorative
  • When a static, non-breathing glow is sufficient — a plain box-shadow or filter: blur() may be lighter