Eclipse
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.
oglfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { OglCanvas } from "@/registry/default/lib/ogl-canvas/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;
}
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 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.
vec2 ptr = uPointer;
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,
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}
dpr={1.75}
reducedMotionTime={13.5}
/>
);
}
Installation
CLI
npx shadcn@latest add @crucible/eclipseManual — install dependencies, then copy the source
npm install oglProps
| 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. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.