# Eclipse — WebGL Background for React

> A total solar eclipse: a black moon disk occludes a dead-center sun ringed by a white-hot corona of writhing radial streamers, a razor limb ring, and one warm diamond-ring bead drifting around the rim over a seeded starfield. Original OGL fragment shader, dithered, offscreen-paused, reduced-motion-safe.

- Page: https://crucibleui.com/components/backgrounds/eclipse
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: ogl
- npm dependencies: ogl

## Install

```bash
npx shadcn@latest add @crucible/eclipse
```

Registry JSON: https://crucibleui.com/r/eclipse.json

Import from `@/components/crucible/eclipse`.

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`EclipseProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors?` | `[string, string, string]` | `["#f4f6ff", "#a8c2ec", "#ffcf8a"]` | Three tones: `[core, streamer, glint]`. `core` is the white-hot inner corona hugging the limb, `streamer` is the cooler outer corona the rays fade into, `glint` is the warm diamond-ring bead. Any hex string. |
| `speed?` | `number` | `1` | Animation speed multiplier: scales the corona's writhe and the diamond glint's travel around the ring. 1 = default, 2 = twice as fast. |
| `intensity?` | `number` | `1` | Corona brightness 0–2: scales the glow, streamers, limb ring, and diamond glint together. The starfield stays fixed so raising it never blows out the sky. |
| `paused?` | `boolean` | `false` | Freeze the animation at its current frame. |
| `idle?` | `boolean` | `true` | Idle autoplay: after ~1.5s with no pointer present, a seeded ghost drifts slowly across the field and shifts the parallax, so thumbnails and heroes never look still; a real pointer takes over instantly. Never runs under reduced motion. |

Also accepts: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
import { Eclipse } from "@/components/crucible/eclipse";
import { DemoScene } from "@/components/registry/demo-scene";

export default function EclipseDemo() {
  return (
    <DemoScene
      background={<Eclipse />}
      headline="Totality"
      subhead="A living solar corona in a single shader — the diamond ring drifts around the rim. Move your cursor to shift the sky."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`eclipse.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { OglCanvas } from "@/lib/ogl-canvas";

export interface EclipseProps extends React.ComponentPropsWithoutRef<"div"> {
  /**
   * Three tones: `[core, streamer, glint]`. `core` is the white-hot inner
   * corona hugging the limb, `streamer` is the cooler outer corona the rays
   * fade into, `glint` is the warm diamond-ring bead. Any hex string.
   * @default ["#f4f6ff", "#a8c2ec", "#ffcf8a"]
   */
  colors?: [string, string, string];
  /**
   * Animation speed multiplier: scales the corona's writhe and the diamond
   * glint's travel around the ring. 1 = default, 2 = twice as fast.
   * @default 1
   */
  speed?: number;
  /**
   * Corona brightness 0–2: scales the glow, streamers, limb ring, and diamond
   * glint together. The starfield stays fixed so raising it never blows out
   * the sky. @default 1
   */
  intensity?: number;
  /** Freeze the animation at its current frame. @default false */
  paused?: boolean;
  /**
   * Idle autoplay: after ~1.5s with no pointer present, a seeded ghost drifts
   * slowly across the field and shifts the parallax, so thumbnails and heroes
   * never look still; a real pointer takes over instantly. Never runs under
   * reduced motion. Default true.
   */
  idle?: boolean;
}

const DEFAULT_COLORS: [string, string, string] = [
  "#f4f6ff", // core — white-hot inner corona
  "#a8c2ec", // streamer — cool ice-blue outer corona
  "#ffcf8a", // glint — warm diamond-ring bead
];

/** Hex → [r,g,b] in 0–1. Accepts #rgb / #rrggbb. */
function hexToRgb(hex: string): [number, number, number] {
  let h = hex.replace("#", "").trim();
  if (h.length === 3) {
    h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
  }
  const int = parseInt(h, 16);
  if (Number.isNaN(int) || h.length !== 6) return [0.96, 0.96, 1.0];
  return [((int >> 16) & 255) / 255, ((int >> 8) & 255) / 255, (int & 255) / 255];
}

/**
 * Original total-solar-eclipse fragment shader. A black moon disk occludes a
 * sun dead-center, ringed by a white-hot corona: broad angular lobes and fine
 * rays are built from seamless direction-space fbm (sampled on the unit circle
 * so the rays are perfectly radial with no atan seam), each streamer reaching
 * farther the brighter it is, so the corona fades outward with a dithered
 * exponential falloff instead of banding. A razor limb ring sits at the moon's
 * edge, and one intense diamond-ring bead — a hot core, soft bloom, and a
 * subtle cross-flare, warm against the neutral corona — travels very slowly
 * around the rim. A seeded starfield sits behind, washed out under the corona
 * and occluded by the disk. Filmic highlight rolloff keeps the bloom clean;
 * grain dithering defeats 8-bit banding. GLSL ES 1.00 (WebGL1/2 compatible).
 */
const FRAGMENT = /* glsl */ `
precision highp float;

varying vec2 vUv;

uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uPointer;
uniform float uPointerActive; // eased 0-1 pointer/ghost presence

uniform vec3 uCore;      // inner corona
uniform vec3 uStreamer;  // outer corona
uniform vec3 uGlint;     // diamond bead
uniform float uSpeed;
uniform float uIntensity;

// Sun/moon radius in vertical-half units (uv.y spans 1.0 across the frame).
const float RIM = 0.15;

float hash21(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}

float vnoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  float a = hash21(i);
  float b = hash21(i + vec2(1.0, 0.0));
  float c = hash21(i + vec2(0.0, 1.0));
  float d = hash21(i + vec2(1.0, 1.0));
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// 4-octave fbm. Rotated lacunarity kills the axis-aligned look.
float fbm(vec2 p) {
  float v = 0.0;
  float amp = 0.5;
  mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
  for (int i = 0; i < 4; i++) {
    v += amp * vnoise(p);
    p = m * p;
    amp *= 0.5;
  }
  return v;
}

void main() {
  vec2 uv = vUv;
  float aspect = uResolution.x / max(uResolution.y, 1.0);
  float resY = max(uResolution.y, 1.0);
  float t = uTime * uSpeed;

  // Subtle parallax: the disk shifts a hair more than the stars with the
  // pointer, giving the void depth. At rest (pointer centered) it's dead center.
  // Presence-scaled so the disk recenters when nobody is there.
  vec2 ptr = mix(vec2(0.5), uPointer, uPointerActive);
  vec2 center = vec2(0.5) + (ptr - 0.5) * 0.025;

  vec2 p = (uv - center) * vec2(aspect, 1.0);
  float rr = length(p);
  vec2 dir = p / max(rr, 1e-4);

  // Distance outside the limb, in sun-radii.
  float x = max(rr - RIM, 0.0) / RIM;

  // ------------------------------------------------------------- corona --
  // Seamless angular fields: sampling fbm on the unit circle makes the value
  // continuous around the ring (period 2pi, no seam) and constant along a
  // radius — i.e. perfectly radial streamers. Slow drift = the writhe.
  float lobes = fbm(dir * 2.4 + t * vec2(0.05, -0.035));
  float rays  = fbm(dir * 6.5 + vec2(9.3, 2.1) - t * vec2(0.045, 0.06));
  float streak = clamp(lobes * 0.62 + rays * 0.48, 0.0, 1.0);
  streak = pow(streak, 1.7);
  // Faint radial breakup so rays are filaments, not perfect spokes.
  streak *= 0.72 + 0.28 * fbm(dir * 9.5 + vec2(0.0, rr * 6.0) - t * 0.05);

  // Brighter streamers reach farther out.
  float reach = mix(0.55, 1.9, streak);
  float streamer = exp(-x / reach) * streak;
  float baseGlow = exp(-x * 1.7) * 0.42;      // smooth halo filling the gaps
  float breathe = 0.94 + 0.06 * sin(t * 0.3);
  float corAmt = (baseGlow + streamer * 1.15) * breathe;

  // Razor limb ring + a soft collar hugging the moon's edge.
  float ringD = (rr - RIM) / RIM;
  float ring = exp(-ringD * ringD * 320.0);
  float collar = exp(-x * 7.0);

  // ------------------------------------------------------- diamond ring --
  float phi = t * 0.06 + 0.4;                 // travels slowly around the rim
  vec2 gdir = vec2(cos(phi), sin(phi));
  float nearG = smoothstep(0.4, 1.0, dot(dir, gdir)); // limb glows near the bead
  vec2 gpos = gdir * (RIM * 1.02);            // just outside the limb
  vec2 gp = p - gpos;
  float gdist = length(gp);
  float gcore = exp(-gdist * gdist * 2200.0);
  float gbloom = exp(-gdist * 20.0);
  float fH = exp(-gp.y * gp.y * 2600.0) * exp(-abs(gp.x) * 9.0);
  float fV = exp(-gp.x * gp.x * 2600.0) * exp(-abs(gp.y) * 9.0);
  float glint = gcore * 3.5 + gbloom * 0.55 + (fH + fV) * 0.45;

  // --------------------------------------------------------------- sky --
  vec3 col = vec3(0.0015, 0.002, 0.005); // faint deep-space cast, never dead #000

  // Seeded starfield, washed out under the corona, parallaxed a touch.
  vec2 sPar = (ptr - 0.5) * 0.008;
  vec2 sc = (uv - sPar) * vec2(aspect, 1.0) * 92.0;
  vec2 sid = floor(sc);
  float pick = hash21(sid);
  float starW = 1.0 - clamp(corAmt * 2.2, 0.0, 1.0);
  if (pick > 0.985) {
    vec2 sp = vec2(hash21(sid + 3.1), hash21(sid + 7.7));
    float dst = length(fract(sc) - sp);
    float b = pow(max(0.0, 1.0 - dst * 3.2), 5.0);
    float tw = 0.55 + 0.45 * sin(t * 1.1 + pick * 54.0);
    col += vec3(0.7, 0.78, 1.0) * b * tw * (0.35 + 0.65 * hash21(sid + 1.7)) * starW;
  }

  // ------------------------------------------------------------ light --
  // Corona hue drifts from white-hot core at the limb to cool streamer far out.
  vec3 cCol = mix(uCore, uStreamer, clamp(x * 0.6, 0.0, 1.0));

  vec3 light = vec3(0.0);
  light += cCol * corAmt * (1.0 + 0.6 * nearG);
  light += mix(uCore, vec3(1.0), 0.5) * ring * (0.9 + 1.4 * nearG);
  light += uCore * collar * (0.35 + 0.5 * nearG);
  light += uGlint * glint;
  col += light * uIntensity;

  // The moon occludes everything: stars behind it, the inner half of the ring.
  float aa = 1.5 / resY;
  float disk = smoothstep(RIM - aa, RIM + aa, rr);
  col *= disk;

  // ---------------------------------------------------------- surface --
  vec2 vg = (uv - 0.5) * vec2(aspect, 1.0);
  col *= 1.0 - 0.4 * dot(vg, vg); // sink the frame edges into black

  // Filmic highlight rolloff: only the hottest points bloom to white.
  col = 1.0 - exp(-col * 1.5);

  // Grain dithering to defeat 8-bit banding across the falloff.
  float grain = hash21(gl_FragCoord.xy + fract(t) * 137.0) - 0.5;
  col += grain * (2.0 / 255.0);

  gl_FragColor = vec4(max(col, 0.0), 1.0);
}
`;

/**
 * Eclipse — a total solar eclipse in GL. A black moon disk occludes a
 * dead-center sun ringed by a white-hot corona: radial streamers writhe and
 * fade outward with a dithered falloff, a razor limb ring burns at the edge,
 * and one warm diamond-ring bead travels slowly around the rim over a seeded
 * starfield. Built on the `ogl-canvas` harness: GPU-light, offscreen-paused,
 * reduced-motion-safe. Position it inside any `relative` container; it fills
 * and sits behind content.
 */
export function Eclipse({
  colors = DEFAULT_COLORS,
  speed = 1,
  intensity = 1,
  paused = false,
  idle = true,
  className,
  ...props
}: EclipseProps) {
  const uniforms = React.useMemo(
    () => ({
      uCore: { value: hexToRgb(colors[0]) },
      uStreamer: { value: hexToRgb(colors[1]) },
      uGlint: { value: hexToRgb(colors[2]) },
      uSpeed: { value: Math.max(speed, 0) },
      uIntensity: { value: Math.min(Math.max(intensity, 0), 2) },
    }),
    [colors, speed, intensity]
  );

  return (
    <OglCanvas
      aria-hidden
      data-crucible="eclipse"
      {...props}
      className={cn("absolute inset-0 -z-10 bg-neutral-950", className)}
      fragment={FRAGMENT}
      uniforms={uniforms}
      paused={paused}
      ghost={idle}
      dpr={1.75}
      reducedMotionTime={13.5}
    />
  );
}
```
