shadimage
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>
  );
}

More from uniquel

Browse all components in this library →