uniquel
componentaurorasource: ok · refreshed
Aurora
Minimal aurora borealis light - fanned curtains in tones of a single green, drifting slowly across a deep night sky.
npx shadcn add "https://uniquel.io/r/aurora.json"free
Visual style
minimal
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 9:39:14 AM
components/backgrounds/AuroraBackground.tsx
'use client';
import { useEffect, useRef, type ReactNode } from 'react';
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
//
// children Content layered above the aurora (headline, CTA, etc.)
// className Styles for the content wrapper
// containerClassName Styles for the outer shell (size, layout)
// theme "dark" (default) | "light"
// colors Aurora tones, sampled left → right across the sky.
// Keep these close in hue — one color's shades reads as
// a real aurora; a rainbow does not. Defaults per theme.
// intensity Overall brightness of the light
// speed "slow" (default) | "medium" | "fast"
//
type AuroraBackgroundProps = {
children?: ReactNode;
className?: string;
containerClassName?: string;
theme?: 'dark' | 'light';
colors?: string[];
intensity?: number;
speed?: 'slow' | 'medium' | 'fast';
};
const THEMES = {
dark: {
sky: '#04030f',
colors: ['#0f766e', '#15916b', '#34d399', '#6ee7b7', '#22c55e'],
blend: 'screen' as const,
},
light: {
sky: '#f2f6f4',
colors: ['#0d9488', '#059669', '#10b981', '#34d399', '#16a34a'],
blend: 'multiply' as const,
},
};
const SPEED_STEP = { slow: 0.006, medium: 0.01, fast: 0.018 } as const;
const randFrom = (n: number) => {
let seed = n;
return () => {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
};
};
type Band = {
kind: 'haze' | 'curtain';
y0: number;
y1: number;
colorT: number;
f1: number;
f2: number;
f3: number;
s1: number;
s2: number;
s3: number;
p1: number;
p2: number;
p3: number;
a1: number;
a2: number;
a3: number;
alpha: number;
folds: { u: number; w: number; p: number; s: number }[];
};
const BANDS: Band[] = (() => {
const rand = randFrom(31);
const bands: Band[] = [];
for (let i = 0; i < 2; i++) {
bands.push({
kind: 'haze',
y0: 0.2 + rand() * 0.08,
y1: 0.6 + rand() * 0.1,
colorT: 0.3 + i * 0.35,
f1: 2.2 + rand(),
f2: 4 + rand() * 1.6,
f3: 0.9 + rand() * 0.5,
s1: 0.3 + rand() * 0.2,
s2: 0.2 + rand() * 0.15,
s3: 0.12 + rand() * 0.1,
p1: rand() * Math.PI * 2,
p2: rand() * Math.PI * 2,
p3: rand() * Math.PI * 2,
a1: 22 + rand() * 12,
a2: 14 + rand() * 10,
a3: 18 + rand() * 10,
alpha: 0.32 + rand() * 0.1,
folds: [],
});
}
for (let i = 0; i < 3; i++) {
bands.push({
kind: 'curtain',
y0: 0.16 + rand() * 0.12,
y1: 0.54 + rand() * 0.16,
colorT: Math.min(1, Math.max(0, i / 2 + (rand() - 0.5) * 0.15)),
f1: 3.2 + rand() * 2.4,
f2: 6.5 + rand() * 3,
f3: 1.2 + rand(),
s1: 0.5 + rand() * 0.35,
s2: 0.32 + rand() * 0.28,
s3: 0.18 + rand() * 0.16,
p1: rand() * Math.PI * 2,
p2: rand() * Math.PI * 2,
p3: rand() * Math.PI * 2,
a1: 36 + rand() * 22,
a2: 22 + rand() * 16,
a3: 28 + rand() * 18,
alpha: 0.5 + rand() * 0.22,
folds: Array.from({ length: 5 + Math.floor(rand() * 3) }, () => ({
u: rand(),
w: 0.06 + rand() * 0.1,
p: rand() * Math.PI * 2,
s: 0.25 + rand() * 0.45,
})),
});
}
return bands;
})();
const hexA = (hex: string, a: number) => {
const n = hex.replace('#', '');
const r = parseInt(n.slice(0, 2), 16);
const g = parseInt(n.slice(2, 4), 16);
const b = parseInt(n.slice(4, 6), 16);
return `rgba(${r},${g},${b},${Math.max(0, Math.min(1, a))})`;
};
const pick = (palette: string[], t: number) =>
palette[Math.round(Math.min(1, Math.max(0, t)) * (palette.length - 1))];
const ribbonY = (
u: number,
t: number,
band: Band,
edge: 'top' | 'bot',
h: number
) => {
const wave =
Math.sin(u * band.f1 + t * band.s1 + band.p1) * band.a1 +
Math.sin(u * band.f2 - t * band.s2 + band.p2) * band.a2 +
Math.sin(u * band.f3 + t * band.s3 + band.p3) * band.a3;
const base = (edge === 'top' ? band.y0 : band.y1) * h;
return base + wave * (edge === 'top' ? 1 : 1.3);
};
export default function AuroraBackground({
children,
className = '',
containerClassName = '',
theme = 'dark',
colors,
intensity = 1,
speed = 'medium',
}: AuroraBackgroundProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef(0);
const timeRef = useRef(0);
const themeTokens = THEMES[theme] ?? THEMES.dark;
const palette = colors ?? themeTokens.colors;
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return;
const step = SPEED_STEP[speed] ?? SPEED_STEP.medium;
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
// Half-res + CSS blur is the bloom. Never use ctx.filter (it stalls the tab).
const SCALE = 0.5;
let width = 0;
let height = 0;
const resize = () => {
const rect = container.getBoundingClientRect();
width = Math.max(1, Math.floor(rect.width * SCALE));
height = Math.max(1, Math.floor(rect.height * SCALE));
canvas.width = width;
canvas.height = height;
};
const traceRibbon = (band: Band, t: number) => {
ctx.beginPath();
const stepX = 10;
for (let x = -20; x <= width + 20; x += stepX) {
const y = ribbonY(x / width, t, band, 'top', height);
if (x === -20) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
for (let x = width + 20; x >= -20; x -= stepX) {
ctx.lineTo(x, ribbonY(x / width, t, band, 'bot', height));
}
ctx.closePath();
};
const drawRibbon = (band: Band, t: number) => {
const color = pick(palette, band.colorT);
const a = band.alpha * intensity;
const g = ctx.createLinearGradient(
0,
band.y0 * height - 20,
0,
band.y1 * height + 24
);
const body = band.kind === 'haze' ? a : a * 0.4;
g.addColorStop(0, hexA(color, 0));
g.addColorStop(0.22, hexA(color, body * 0.3));
g.addColorStop(0.48, hexA(color, body));
g.addColorStop(0.8, hexA(color, body * 0.35));
g.addColorStop(1, hexA(color, 0));
traceRibbon(band, t);
ctx.fillStyle = g;
ctx.fill();
if (band.kind !== 'curtain') return;
ctx.save();
traceRibbon(band, t);
ctx.clip();
for (const fold of band.folds) {
const x =
(fold.u + Math.sin(t * fold.s + fold.p) * 0.04) * width;
const pulse = 0.55 + 0.45 * Math.sin(t * fold.s * 1.4 + fold.p);
const fw = fold.w * width;
const fg = ctx.createLinearGradient(x - fw, 0, x + fw, 0);
fg.addColorStop(0, hexA(color, 0));
fg.addColorStop(0.5, hexA(color, a * 0.55 * pulse));
fg.addColorStop(1, hexA(color, 0));
ctx.fillStyle = fg;
ctx.fillRect(x - fw, 0, fw * 2, height);
}
ctx.restore();
};
const draw = () => {
timeRef.current += prefersReducedMotion ? 0 : step;
const t = timeRef.current;
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = themeTokens.sky;
ctx.fillRect(0, 0, width, height);
ctx.globalCompositeOperation = themeTokens.blend;
for (const band of BANDS) drawRibbon(band, t);
ctx.globalCompositeOperation = 'source-over';
if (!prefersReducedMotion) {
rafRef.current = requestAnimationFrame(draw);
}
};
resize();
draw();
const observer = new ResizeObserver(resize);
observer.observe(container);
return () => {
cancelAnimationFrame(rafRef.current);
observer.disconnect();
};
}, [intensity, palette, speed, themeTokens.blend, themeTokens.sky]);
return (
<div
ref={containerRef}
className={[
'relative flex min-h-[480px] w-full flex-col items-center justify-center overflow-hidden',
containerClassName,
]
.filter(Boolean)
.join(' ')}
style={{ backgroundColor: themeTokens.sky }}
>
<canvas
ref={canvasRef}
className='absolute inset-0 z-0 h-full w-full scale-110 blur-[14px]'
aria-hidden
/>
<div
className={['relative z-10 px-6 text-center', className]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
</div>
);
}