{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"aurora-background","type":"registry:component","title":"Aurora Background","description":"Soft colour blobs rising from an edge and drifting behind your content.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/aurora-background/aurora-background.tsx","type":"registry:component","target":"components/dimaac/aurora-background.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// Soft colour blobs that rise from an edge and drift slowly behind your content, with an optional\n// giant faded word in the glow. It is the DiMaac footer aurora, made into a component.\n// It stays cheap: every blob is one div with a gradient and a blur, and the drift only moves\n// transforms, so the browser's GPU does the work. It pauses when it scrolls out of view.\n\nexport type AuroraPalette = \"dimaac\" | \"sunset\" | \"ocean\" | \"ember\" | \"mono\";\n\nexport type AuroraBackgroundProps = {\n  /** Four CSS colours for the four blobs, left to right. Overrides `palette`. Fewer than four repeat. */\n  colors?: string[];\n  /** A ready-made set of four colours. \"dimaac\" is teal, mint, violet and pink. */\n  palette?: AuroraPalette;\n  /** How strong the whole glow is, from 0 (gone) to 1 (full). */\n  intensity?: number;\n  /** How soft the blobs are, as a blur in pixels. More blur melts the colours together. */\n  blur?: number;\n  /** How fast the blobs drift. 1 is slow and calm, 0 holds them still, 3 is lively. */\n  speed?: number;\n  /** The edge the glow rises from. */\n  position?: \"bottom\" | \"top\" | \"center\";\n  /** A giant faded word sitting in the glow. Leave it out (or null) for none. */\n  wordmark?: string | null;\n  /** Film grain over the glow, from 0 (none) to 0.2 (heavy). It hides colour banding. */\n  grain?: number;\n  /** Which tuning to use. \"auto\" follows the page: data-theme, a dark class, or the system setting. */\n  theme?: \"auto\" | \"light\" | \"dark\";\n  /** Extra classes for the outer box. Give it a size, or let your content size it. */\n  className?: string;\n  /** Your content. It sits above the glow. */\n  children?: ReactNode;\n};\n\n// ---------- colours, tuned once for light and once for dark ----------\n// On paper the glow can be strong and the colours deep. On near black the same colours would\n// glare, so the mint is turned down and the teal and violet carry the light instead.\n\nconst palettes: Record<AuroraPalette, { light: string[]; dark: string[] }> = {\n  dimaac: { light: [\"#0f766e\", \"#99f6e4\", \"#7c3aed\", \"#f9a8d4\"], dark: [\"#14b8a6\", \"#99f6e4\", \"#7c3aed\", \"#f9a8d4\"] },\n  sunset: { light: [\"#f97316\", \"#fde68a\", \"#e11d48\", \"#a855f7\"], dark: [\"#fb923c\", \"#fde68a\", \"#e11d48\", \"#a855f7\"] },\n  ocean: { light: [\"#0284c7\", \"#a5f3fc\", \"#4f46e5\", \"#67e8f9\"], dark: [\"#38bdf8\", \"#a5f3fc\", \"#4f46e5\", \"#67e8f9\"] },\n  ember: { light: [\"#dc2626\", \"#fdba74\", \"#9f1239\", \"#fb7185\"], dark: [\"#ef4444\", \"#fdba74\", \"#be123c\", \"#fb7185\"] },\n  mono: { light: [\"#525252\", \"#d4d4d4\", \"#262626\", \"#a3a3a3\"], dark: [\"#d4d4d4\", \"#737373\", \"#a3a3a3\", \"#525252\"] },\n};\n\n// How strong each of the four blobs is\nconst strength = {\n  light: [0.55, 0.9, 0.45, 0.55],\n  dark: [0.8, 0.38, 0.62, 0.34],\n};\n\nconst wordmarkInk = {\n  light: [\"rgba(10,10,10,0.16)\", \"rgba(10,10,10,0.06)\", \"rgba(255,255,255,0.35)\"],\n  dark: [\"rgba(255,255,255,0.12)\", \"rgba(255,255,255,0.04)\", \"rgba(255,255,255,0.22)\"],\n};\n\n// Each blob: where it sits across the width (%), how wide and tall it is (% of the glow layer),\n// and how it drifts: sideways and up/down in % of its own size, the scale it breathes between,\n// the seconds one swing takes, and how far into the swing it starts so they never move together.\nconst blobs = [\n  { x: 18, width: 80, height: 120, driftX: 10, driftY: 9, scale: 1.12, seconds: 9, start: 0 },\n  { x: 42, width: 84, height: 140, driftX: -8, driftY: 12, scale: 1.08, seconds: 12, start: 0.35 },\n  { x: 66, width: 80, height: 130, driftX: 9, driftY: 8, scale: 1.14, seconds: 10, start: 0.7 },\n  { x: 88, width: 60, height: 100, driftX: -11, driftY: 11, scale: 1.1, seconds: 14, start: 0.15 },\n] as const;\n\n// Grain: a small tile of grey noise, drawn once as an SVG. Overlay blending keeps it neutral on\n// both light and dark pages.\nconst noise =\n  \"url(\\\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 1.6 -0.3'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\\\")\";\n\n// ---------- \"is the page dark?\" without any site context ----------\n\nfunction pageIsDark(): boolean {\n  const root = document.documentElement;\n  const picked = root.getAttribute(\"data-theme\");\n  if (picked === \"dark\") return true;\n  if (picked === \"light\") return false;\n  if (root.classList.contains(\"dark\")) return true;\n  if (root.classList.contains(\"light\")) return false;\n  return window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n}\n\nfunction watchPageTheme(onChange: () => void): () => void {\n  const observer = new MutationObserver(onChange);\n  observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\", \"class\"] });\n  const query = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  query.addEventListener(\"change\", onChange);\n  return () => {\n    observer.disconnect();\n    query.removeEventListener(\"change\", onChange);\n  };\n}\n\nexport function AuroraBackground({\n  colors,\n  palette = \"dimaac\",\n  intensity = 0.85,\n  blur = 48,\n  speed = 1,\n  position = \"bottom\",\n  wordmark = null,\n  grain = 0,\n  theme = \"auto\",\n  className = \"\",\n  children,\n}: AuroraBackgroundProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const blobRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const swings = useRef<gsap.core.Tween[]>([]);\n  // Read by the drift so a new speed never rebuilds it\n  const speedRef = useRef(speed);\n  speedRef.current = speed;\n\n  // On a narrow box (a phone) the blobs would shrink into thin streaks, so they are drawn wider\n  const [compact, setCompact] = useState(false);\n  useEffect(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    const watch = new ResizeObserver(([entry]) => setCompact((entry?.contentRect.width ?? 1000) < 640));\n    watch.observe(box);\n    return () => watch.disconnect();\n  }, []);\n\n  // The server renders the light tuning; the browser switches to dark right after if it should\n  const pageDark = useSyncExternalStore(watchPageTheme, pageIsDark, () => false);\n  const mode = theme === \"auto\" ? (pageDark ? \"dark\" : \"light\") : theme;\n\n  // ---------- the drift ----------\n  useGSAP(\n    () => {\n      const box = boxRef.current;\n      const elements = blobRefs.current.filter((el): el is HTMLDivElement => el !== null);\n      if (!box || elements.length === 0) return;\n\n      const mm = gsap.matchMedia();\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        // One endless back-and-forth swing per blob; yoyo makes it ease out and back with no jump\n        swings.current = elements.map((el, i) => {\n          const blob = blobs[i]!;\n          return gsap.to(el, {\n            xPercent: blob.driftX,\n            yPercent: -blob.driftY,\n            scale: blob.scale,\n            duration: blob.seconds,\n            ease: \"sine.inOut\",\n            repeat: -1,\n            yoyo: true,\n            force3D: true,\n          });\n        });\n        swings.current.forEach((swing, i) => {\n          swing.progress(blobs[i]!.start);\n          swing.timeScale(speedRef.current);\n        });\n\n        // Nothing moves while the glow is off screen\n        const visibility = new IntersectionObserver(([entry]) => {\n          const on = entry?.isIntersecting ?? true;\n          swings.current.forEach((swing) => (on ? swing.resume() : swing.pause()));\n        });\n        visibility.observe(box);\n\n        return () => {\n          visibility.disconnect();\n          swings.current = [];\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: boxRef, dependencies: [position], revertOnUpdate: true },\n  );\n\n  // A new speed only changes how fast the swings play\n  useEffect(() => {\n    swings.current.forEach((swing) => swing.timeScale(Math.max(0, speed)));\n  }, [speed]);\n\n  // ---------- what to draw ----------\n  const picked = colors && colors.length > 0 ? colors : palettes[palette][mode];\n  const colourFor = (i: number) => picked[i % picked.length]!;\n  const alphas = strength[mode];\n  // Where the blobs are centred, up and down the glow layer\n  const centreY = position === \"bottom\" ? 100 : position === \"top\" ? 0 : 50;\n  // The glow layer runs 30% past the edge it rises from, so the blobs' round bottoms stay off screen\n  const layer: CSSProperties =\n    position === \"bottom\" ? { top: 0, height: \"130%\" } : position === \"top\" ? { top: \"-30%\", height: \"130%\" } : { top: \"-15%\", height: \"130%\" };\n\n  const ink = wordmarkInk[mode];\n  const wordPlace: CSSProperties =\n    position === \"bottom\"\n      ? { bottom: \"-0.2em\", backgroundImage: `linear-gradient(180deg, ${ink[0]} 0%, ${ink[1]} 55%, ${ink[2]} 100%)` }\n      : position === \"top\"\n        ? { top: \"-0.2em\", backgroundImage: `linear-gradient(0deg, ${ink[0]} 0%, ${ink[1]} 55%, ${ink[2]} 100%)` }\n        : { top: \"50%\", transform: \"translateY(-50%)\", backgroundImage: `linear-gradient(180deg, ${ink[1]} 0%, ${ink[0]} 50%, ${ink[1]} 100%)` };\n\n  const widen = compact ? 1.7 : 1;\n  const safeBlur = Math.max(0, blur);\n\n  return (\n    <div ref={boxRef} className={`relative h-full w-full overflow-hidden ${className}`} style={{ containerType: \"inline-size\" }}>\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute\" style={{ left: \"-10%\", right: \"-10%\", opacity: Math.min(1, Math.max(0, intensity)), ...layer }}>\n        {blobs.map((blob, i) => (\n          <div\n            key={i}\n            ref={(el) => {\n              blobRefs.current[i] = el;\n            }}\n            className=\"absolute\"\n            style={{\n              left: `${blob.x - (blob.width * widen) / 2}%`,\n              top: `${centreY - blob.height / 2}%`,\n              width: `${blob.width * widen}%`,\n              height: `${blob.height}%`,\n              // Fades from the colour at the heart to nothing at 70% of the way out, like the footer\n              backgroundImage: `radial-gradient(closest-side, color-mix(in srgb, ${colourFor(i)} ${alphas[i]! * 100}%, transparent), transparent 70%)`,\n              filter: safeBlur > 0 ? `blur(${safeBlur}px)` : undefined,\n              willChange: \"transform\",\n            }}\n          />\n        ))}\n      </div>\n\n      {wordmark ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute right-0 left-0 text-center leading-none font-medium whitespace-nowrap select-none\"\n          style={{\n            fontSize: \"clamp(96px, 25cqw, 380px)\",\n            letterSpacing: \"-0.07em\",\n            color: \"transparent\",\n            WebkitBackgroundClip: \"text\",\n            backgroundClip: \"text\",\n            ...wordPlace,\n          }}\n        >\n          {wordmark}\n        </div>\n      ) : null}\n\n      {grain > 0 ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n          style={{ backgroundImage: noise, backgroundSize: \"160px 160px\", mixBlendMode: \"overlay\", opacity: Math.min(1, grain * 5) }}\n        />\n      ) : null}\n\n      <div className=\"relative h-full w-full\">{children}</div>\n    </div>\n  );\n}\n"}],"categories":["backgrounds","background","gradient","aurora","css","ambient"],"docs":"import { AuroraBackground } from \"@/components/dimaac/aurora-background\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/aurora-background","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/aurora-background","credits":[]}}