Corona
Crucible's signature aurora: ribboned bands with a moving metallic sheen on a near-black field, in molten forge tones. Original OGL fragment shader, offscreen-paused and 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 CoronaProps {
/**
* Three forge tones for the ribbon gradient: `[molten, ember, violet]`.
* Any hex string. Defaults to amber / ember red / deep violet.
*/
colors?: [string, string, string];
/** Number of ribbon bands, clamped 3–6. Default 4. */
bands?: number;
/** Flow speed multiplier. 1 = default. Default 1. */
speed?: number;
/** Overall brightness / bloom of the ribbons. 1 = default. Default 1. */
intensity?: number;
/** Freeze the animation, holding the current frame. */
paused?: boolean;
/** Extra classes for the wrapper. Positioned `absolute inset-0 -z-10`. */
className?: string;
}
const DEFAULT_COLORS: [string, string, string] = [
"#ff6b35", // molten amber
"#c1121f", // ember red
"#5a189a", // deep violet
];
/** Hex → linear-ish [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 [1, 0.42, 0.21];
return [((int >> 16) & 255) / 255, ((int >> 8) & 255) / 255, (int & 255) / 255];
}
/**
* Original ribbon-aurora fragment shader. Up to 6 horizontal flowing bands,
* each a value-noise-displaced crest with soft vertical falloff, a moving
* specular "metallic sheen" along the crest, pointer warp, and film-grain
* dithering to defeat 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 uColorA; // molten
uniform vec3 uColorB; // ember
uniform vec3 uColorC; // violet
uniform float uBands;
uniform float uSpeed;
uniform float uIntensity;
const int MAX_BANDS = 6;
// --- compact value noise + fbm (original) ---
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);
}
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 < 5; i++) {
v += amp * vnoise(p);
p = m * p;
amp *= 0.5;
}
return v;
}
// Palette across a band's length, biased by band index.
// Triangle-wave the phase so the ramp is periodic and continuous: a raw
// fract() here wraps hi -> lo the instant the argument crosses an integer,
// which paints a hard vertical seam mid-screen.
vec3 ribbonColor(float x, float idx) {
float v = x * 0.5 + idx * 0.37;
float t = abs(fract(v) - 0.5) * 2.0;
vec3 lo = mix(uColorC, uColorB, smoothstep(0.0, 0.55, t));
vec3 hi = mix(lo, uColorA, smoothstep(0.35, 1.0, t));
return hi;
}
void main() {
vec2 uv = vUv;
float aspect = uResolution.x / max(uResolution.y, 1.0);
float t = uTime * uSpeed;
int bandCount = int(clamp(uBands, 3.0, float(MAX_BANDS)));
// Near-black base with a faint violet lift toward the bottom.
vec3 col = mix(vec3(0.012, 0.010, 0.020), uColorC * 0.06, pow(1.0 - uv.y, 1.6));
// Pointer influence, aspect-corrected so warp is circular.
vec2 pd = vec2((uv.x - uPointer.x) * aspect, uv.y - uPointer.y);
float pointerFall = exp(-dot(pd, pd) * 6.0);
for (int i = 0; i < MAX_BANDS; i++) {
if (i >= bandCount) break;
float fi = float(i);
// Vertical home of this band, evenly spread across the mid field.
float base = mix(0.24, 0.80, (fi + 0.5) / float(bandCount));
// Noise-displaced crest. Two octaves scrolling at different rates read
// as a living ribbon rather than a sine wave.
float nx = uv.x * 2.3 + fi * 4.1;
float disp = fbm(vec2(nx + t * 0.35, fi * 7.0 + t * 0.12)) - 0.5;
disp += 0.35 * (fbm(vec2(nx * 1.9 - t * 0.22, fi * 3.0)) - 0.5);
// Pointer gently pushes the nearest ribbons.
disp += pointerFall * 0.06 * (uPointer.y - base) * 4.0;
float crest = base + disp * 0.16;
// Soft vertical falloff around the crest — the ribbon body.
float d = uv.y - crest;
float thickness = 0.055 + 0.02 * vnoise(vec2(nx, fi));
float body = exp(-(d * d) / (thickness * thickness));
vec3 c = ribbonColor(uv.x, fi);
// Metallic sheen: a narrow highlight that slides along the crest and
// tightens where the ribbon is thin. Reads as a moving specular glint.
float sheenPhase = fract(t * 0.06 + fi * 0.19);
float alongX = abs(fract(uv.x - sheenPhase + 0.5) - 0.5);
float sheen = smoothstep(0.16, 0.0, alongX) * body;
sheen *= smoothstep(0.14, 0.02, abs(d)); // hug the crest
vec3 sheenTint = mix(c, vec3(1.0, 0.93, 0.82), 0.7);
col += c * body * 0.9;
col += sheenTint * sheen * 1.4;
}
col *= uIntensity;
// Soft tone curve + vignette so bloom doesn't clip harshly.
col = col / (col + vec3(0.75));
vec2 vg = (uv - 0.5) * vec2(aspect, 1.0);
col *= 1.0 - 0.35 * dot(vg, vg);
// Film-grain dithering to defeat 8-bit banding.
float grain = hash21(gl_FragCoord.xy + fract(t) * 137.0) - 0.5;
col += grain * (1.6 / 255.0);
gl_FragColor = vec4(max(col, 0.0), 1.0);
}
`;
/**
* Corona — Crucible's signature aurora. Ribboned bands with a moving metallic
* sheen on a near-black field, in molten forge tones. Built on the `ogl-canvas`
* harness: GPU-light, offscreen-paused, reduced-motion-safe. Drop it inside any
* `relative` container; it fills and sits behind content.
*/
export function Corona({
colors = DEFAULT_COLORS,
bands = 4,
speed = 1,
intensity = 1,
paused = false,
className,
}: CoronaProps) {
const uniforms = React.useMemo(
() => ({
uColorA: { value: hexToRgb(colors[0]) },
uColorB: { value: hexToRgb(colors[1]) },
uColorC: { value: hexToRgb(colors[2]) },
uBands: { value: bands },
uSpeed: { value: speed },
uIntensity: { value: intensity },
}),
[colors, bands, speed, intensity]
);
return (
<OglCanvas
aria-hidden
data-crucible="corona"
className={cn("absolute inset-0 -z-10", className)}
fragment={FRAGMENT}
uniforms={uniforms}
paused={paused}
/>
);
}
Installation
CLI
npx shadcn@latest add @crucible/coronaManual — install dependencies, then copy the source
npm install oglHonors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.