{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"An AuroraCurtain background component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\n\nexport type AuroraDirection = \"up\" | \"down\";\n\nexport interface AuroraCurtainProps {\n  /** Number of aurora ribbons rendered behind the content. Clamped to 1-8. */\n  bands?: number;\n  /** Softness of every ribbon edge, from 0 (crisp) to 1 (very diffuse). */\n  blur?: number;\n  /** Foreground content, always painted above the decorative curtain. */\n  children?: ReactNode;\n  /** Extra classes for the positioned wrapper element. */\n  className?: string;\n  /**\n   * Ribbon colours. Accepts any CSS colour, a `var(--token)` expression, or a\n   * bare custom property name such as `--color-brand`.\n   */\n  colors?: string[];\n  /** Direction the light flows towards. */\n  direction?: AuroraDirection;\n  /** Overall brightness multiplier, from 0 to 1. */\n  intensity?: number;\n  /**\n   * Film-grain strength from `0` (clean) to `1` (heavily filmic). The grain is\n   * re-seeded every frame and covers the whole surface, not only the ribbons,\n   * so it reads as moving film stock rather than dust on the glass.\n   * `true` is accepted as a shorthand for `0.6`, `false` for `0`.\n   */\n  noise?: number | boolean;\n  /** Freezes the animation on the current frame. */\n  paused?: boolean;\n  /** Drift speed multiplier. 1 is the calibrated default. */\n  speed?: number;\n}\n\ntype Rgb = [number, number, number];\n\ninterface AuroraSettings {\n  bands: number;\n  blur: number;\n  colors: Rgb[];\n  direction: number;\n  grain: number;\n  intensity: number;\n  speed: number;\n}\n\ninterface AuroraController {\n  destroy: () => void;\n  render: () => void;\n  resize: () => void;\n  setRunning: (running: boolean) => void;\n  setSettings: (settings: AuroraSettings) => void;\n}\n\nconst MAX_DPR = 2;\nconst MAX_COLORS = 6;\nconst MAX_BANDS = 8;\nconst MIN_BANDS = 1;\nconst MS_PER_SECOND = 1000;\nconst GRAIN_SHORTHAND = 0.6;\nconst GRAIN_SEED_CYCLE = 977;\nconst RGB_MAX = 255;\nconst DEFAULT_BANDS = 4;\nconst DEFAULT_SPEED = 1;\nconst DEFAULT_BLUR = 0.5;\nconst DEFAULT_INTENSITY = 1;\n\n/**\n * Theme tokens first; the literal after the comma is the same colour written\n * out for consumers who install the component without SmoothUI's theme. Every\n * literal here is authored in oklch so the three ribbons sit at one perceptual\n * lightness with each hue taking a comparable share of its own chroma ceiling.\n */\nconst DEFAULT_COLORS = [\n  \"var(--color-green, oklch(0.78 0.16 148))\",\n  \"var(--color-blue, oklch(0.79 0.11 196))\",\n  \"var(--color-brand, oklch(0.70 0.17 318))\",\n];\n\n/**\n * sRGB conversions of the three oklch literals above, used only when the\n * document cannot be probed for computed colours.\n * oklch(0.78 0.16 148) · oklch(0.79 0.11 196) · oklch(0.70 0.17 318)\n */\nconst FALLBACK_COLORS: Rgb[] = [\n  [0.399, 0.826, 0.48],\n  [0.323, 0.817, 0.822],\n  [0.79, 0.471, 0.886],\n];\n\nconst VERTEX_SHADER = `#version 300 es\nin vec2 aPosition;\nvoid main() {\n  gl_Position = vec4(aPosition, 0.0, 1.0);\n}`;\n\nconst FRAGMENT_SHADER = `#version 300 es\nprecision highp float;\n\nout vec4 fragColor;\n\nuniform vec2 uRes;\nuniform float uTime;\nuniform float uBands;\nuniform float uSpeed;\nuniform float uBlur;\nuniform float uIntensity;\nuniform float uDirection;\nuniform float uGrain;\nuniform float uSeed;\nuniform float uPixel;\nuniform int uColorCount;\nuniform vec3 uColors[6];\n\nconst int MAX_BANDS = 8;\n\nfloat hash21(vec2 p) {\n  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\n// Grain hash: the per-frame seed is folded in so the pattern is reborn every\n// frame instead of sitting on the screen like dust on the glass.\nfloat grainHash(vec2 p, float seed) {\n  return fract(sin(dot(p, vec2(12.9898, 78.233)) + seed * 1.6180339) * 43758.5453123);\n}\n\nfloat vnoise(vec2 p) {\n  vec2 i = floor(p);\n  vec2 f = fract(p);\n  float a = hash21(i);\n  float b = hash21(i + vec2(1.0, 0.0));\n  float c = hash21(i + vec2(0.0, 1.0));\n  float d = hash21(i + vec2(1.0, 1.0));\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\n// Two-octave detail, normalised to 0..1. Used where a second octave changes\n// the silhouette; anything that only needs a slow scalar uses sines instead,\n// because every octave is four more transcendentals per fragment.\nfloat fbm2(vec2 p) {\n  float value = vnoise(p) * 0.5 + vnoise(p * 2.07 + 9.31) * 0.25;\n  return value * 1.3333333;\n}\n\n// Three-octave meander for the ribbon path, normalised to 0..1.\nfloat fbm3(vec2 p) {\n  float value = 0.0;\n  float amplitude = 0.5;\n  for (int i = 0; i < 3; i++) {\n    value += amplitude * vnoise(p);\n    p = p * 2.03 + 7.13;\n    amplitude *= 0.5;\n  }\n  return value * 1.1428571;\n}\n\nvec3 bandColor(int index) {\n  int count = max(uColorCount, 1);\n  int slot = index - (index / count) * count;\n  return uColors[slot];\n}\n\nvoid main() {\n  vec2 uv = gl_FragCoord.xy / uRes;\n  float flow = mix(1.0, -1.0, uDirection);\n  float y = mix(uv.y, 1.0 - uv.y, uDirection);\n\n  // Vertical falloff: dense near the base, dissolving towards the far edge\n  // the way a real aurora thins out as it climbs.\n  float rise = smoothstep(-0.10, 0.26, y);\n  float fade = 1.0 - smoothstep(0.16, 1.08, y);\n  float envelope = rise * fade * fade;\n\n  vec3 accum = vec3(0.0);\n  float alpha = 0.0;\n  float bandCount = clamp(uBands, 1.0, float(MAX_BANDS));\n  float spacing = 0.92 / bandCount;\n  float t = uTime * uSpeed;\n\n  for (int i = 0; i < MAX_BANDS; i++) {\n    if (float(i) >= bandCount) {\n      break;\n    }\n    float fi = float(i);\n    float seed = fi * 13.37;\n\n    // Path: a three-octave meander, a faster single-octave wobble and a slow\n    // sine fold, all evaluated per height, so each ribbon folds along its own\n    // length instead of sliding across as a rigid bar.\n    float driftA = fbm3(vec2(y * 1.35 + seed, t * 0.16 + seed)) - 0.5;\n    float driftB = vnoise(vec2(y * 3.40 - seed, t * 0.27 + seed * 0.5)) - 0.5;\n    float fold = sin(y * 4.1 + t * 0.42 * flow + seed) * 0.045;\n    float center = 0.04 + spacing * (fi + 0.5) + driftA * 0.52 + driftB * 0.18 + fold;\n\n    // Width and softness vary ALONG the ribbon, pinching towards both ends.\n    float widthNoise = fbm2(vec2(y * 2.7 + seed * 2.0, t * 0.22 + seed));\n    float pinch = smoothstep(0.0, 0.22, y) * (1.0 - smoothstep(0.55, 1.08, y));\n    float width = (0.010 + uBlur * 0.052)\n      * mix(0.30, 1.85, widthNoise)\n      * (0.45 + 0.75 * pinch);\n    width = max(width, 0.0025);\n\n    // A bright narrow core wrapped in a much wider halo that falls off at the\n    // edges, rather than one flat gaussian per ribbon.\n    float dx = (uv.x - center) / width;\n    float d2 = dx * dx;\n    float core = exp(-d2 * 2.60);\n    float halo = exp(-d2 * 0.22);\n    float ribbon = core * 1.30 + halo * 0.34;\n\n    // Fine vertical striations scrolling along the flow direction, and a slow\n    // brightness swell. The swell is two sines, not another noise octave.\n    float rays = 0.45 + 0.85 * fbm2(vec2(uv.x * 13.0 + seed, y * 1.7 - flow * t * 0.55));\n    float breathe = 0.78 + 0.22 * sin(t * 0.31 + seed) * cos(t * 0.17 + seed * 0.7);\n\n    float weight = ribbon * envelope * rays * breathe * 0.46;\n    vec3 hot = mix(bandColor(i), vec3(1.0), clamp(core * 0.42, 0.0, 0.45));\n    accum += hot * weight;\n    alpha += weight;\n  }\n\n  // Ground haze so the ribbons sit inside atmosphere rather than on nothing.\n  float haze = envelope * (1.0 - smoothstep(0.0, 0.58, y)) * 0.10;\n  accum += bandColor(0) * haze;\n  alpha += haze;\n\n  alpha = clamp(alpha * uIntensity, 0.0, 1.0);\n  accum = max(accum * uIntensity, vec3(0.0));\n\n  if (uGrain > 0.0) {\n    // One grain cell per CSS pixel. Two hashes averaged give a triangular\n    // distribution, which reads far more photographic than a flat one, and\n    // costs two extra sines rather than a whole extra noise octave.\n    vec2 cell = floor(gl_FragCoord.xy / max(uPixel, 1.0));\n    float g = grainHash(cell, uSeed) + grainHash(cell + vec2(41.7, 17.3), uSeed + 7.31) - 1.0;\n    float amp = uGrain * 0.42;\n\n    // Modulate the light where there is light...\n    accum *= 1.0 + g * amp * 1.10;\n\n    // ...then lay a symmetric veil over the WHOLE surface. Positive grain adds\n    // light; negative grain adds coverage with no colour, which darkens the\n    // backdrop through the premultiplied blend. Without the second half the\n    // grain would only ever brighten, and empty areas would stay glassy.\n    float bright = max(g, 0.0) * amp * 0.48;\n    float dark = max(-g, 0.0) * amp * 0.34;\n    accum += vec3(bright);\n    alpha = clamp(alpha + bright + dark, 0.0, 1.0);\n  }\n\n  fragColor = vec4(max(accum, vec3(0.0)), alpha);\n}`;\n\nconst QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.max(min, Math.min(max, value));\n\nconst toCssColor = (input: string) =>\n  input.trim().startsWith(\"--\") ? `var(${input.trim()})` : input.trim();\n\nconst resolveGrain = (noise: number | boolean) => {\n  if (typeof noise === \"boolean\") {\n    return noise ? GRAIN_SHORTHAND : 0;\n  }\n  return clamp(noise, 0, 1);\n};\n\nconst resolveCssColors = (inputs: string[], host: HTMLElement): Rgb[] => {\n  const probe = document.createElement(\"span\");\n  probe.style.display = \"none\";\n  host.append(probe);\n\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = 1;\n  canvas.height = 1;\n  const context = canvas.getContext(\"2d\", { willReadFrequently: true });\n\n  const resolved = inputs.map((input, index) => {\n    const fallback = FALLBACK_COLORS[index % FALLBACK_COLORS.length];\n    if (!context) {\n      return fallback;\n    }\n    probe.style.color = \"\";\n    probe.style.color = toCssColor(input);\n    const computed = window.getComputedStyle(probe).color;\n    if (!computed) {\n      return fallback;\n    }\n    context.clearRect(0, 0, 1, 1);\n    context.fillStyle = \"#000000\";\n    context.fillStyle = computed;\n    context.fillRect(0, 0, 1, 1);\n    const { data } = context.getImageData(0, 0, 1, 1);\n    return [data[0] / RGB_MAX, data[1] / RGB_MAX, data[2] / RGB_MAX] as Rgb;\n  });\n\n  probe.remove();\n  return resolved;\n};\n\nconst compileShader = (\n  gl: WebGL2RenderingContext,\n  type: number,\n  source: string\n) => {\n  const shader = gl.createShader(type);\n  if (!shader) {\n    return null;\n  }\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    gl.deleteShader(shader);\n    return null;\n  }\n  return shader;\n};\n\nconst createAuroraController = (\n  canvas: HTMLCanvasElement\n): AuroraController | null => {\n  const gl = canvas.getContext(\"webgl2\", {\n    alpha: true,\n    antialias: false,\n    premultipliedAlpha: true,\n  });\n  if (!gl) {\n    return null;\n  }\n\n  const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);\n  const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);\n  const program = gl.createProgram();\n  if (!(vertex && fragment && program)) {\n    return null;\n  }\n\n  gl.attachShader(program, vertex);\n  gl.attachShader(program, fragment);\n  gl.linkProgram(program);\n  gl.deleteShader(vertex);\n  gl.deleteShader(fragment);\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    gl.deleteProgram(program);\n    return null;\n  }\n\n  // biome-ignore lint/correctness/useHookAtTopLevel: WebGL2RenderingContext.useProgram is not a React hook.\n  gl.useProgram(program);\n\n  const buffer = gl.createBuffer();\n  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n  gl.bufferData(gl.ARRAY_BUFFER, QUAD, gl.STATIC_DRAW);\n  const position = gl.getAttribLocation(program, \"aPosition\");\n  gl.enableVertexAttribArray(position);\n  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);\n  gl.enable(gl.BLEND);\n  gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n\n  const uRes = gl.getUniformLocation(program, \"uRes\");\n  const uTime = gl.getUniformLocation(program, \"uTime\");\n  const uBands = gl.getUniformLocation(program, \"uBands\");\n  const uSpeed = gl.getUniformLocation(program, \"uSpeed\");\n  const uBlur = gl.getUniformLocation(program, \"uBlur\");\n  const uIntensity = gl.getUniformLocation(program, \"uIntensity\");\n  const uDirection = gl.getUniformLocation(program, \"uDirection\");\n  const uGrain = gl.getUniformLocation(program, \"uGrain\");\n  const uSeed = gl.getUniformLocation(program, \"uSeed\");\n  const uPixel = gl.getUniformLocation(program, \"uPixel\");\n  const uColorCount = gl.getUniformLocation(program, \"uColorCount\");\n  const uColors = gl.getUniformLocation(program, \"uColors\");\n\n  const palette = new Float32Array(MAX_COLORS * 3);\n  const startedAt = performance.now();\n  let settings: AuroraSettings = {\n    bands: DEFAULT_BANDS,\n    blur: DEFAULT_BLUR,\n    colors: FALLBACK_COLORS,\n    direction: 0,\n    grain: 0,\n    intensity: DEFAULT_INTENSITY,\n    speed: DEFAULT_SPEED,\n  };\n  let colorCount = FALLBACK_COLORS.length;\n  let pixelRatio = 1;\n  let grainSeed = 0;\n  let frame = 0;\n  let running = false;\n  let destroyed = false;\n\n  const applyPalette = () => {\n    colorCount = clamp(settings.colors.length, 1, MAX_COLORS);\n    for (let i = 0; i < colorCount; i++) {\n      const rgb = settings.colors[i] ?? FALLBACK_COLORS[0];\n      palette[i * 3] = rgb[0];\n      palette[i * 3 + 1] = rgb[1];\n      palette[i * 3 + 2] = rgb[2];\n    }\n  };\n  applyPalette();\n\n  const resize = () => {\n    const rect = canvas.getBoundingClientRect();\n    const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);\n    pixelRatio = dpr;\n    const width = Math.max(1, Math.round(rect.width * dpr));\n    const height = Math.max(1, Math.round(rect.height * dpr));\n    if (canvas.width !== width || canvas.height !== height) {\n      canvas.width = width;\n      canvas.height = height;\n      gl.viewport(0, 0, width, height);\n    }\n  };\n\n  const draw = () => {\n    if (destroyed) {\n      return;\n    }\n    // A frame counter, not Math.random: deterministic, and it re-seeds the\n    // grain hash so the pattern never freezes on screen.\n    grainSeed = (grainSeed + 1) % GRAIN_SEED_CYCLE;\n    gl.clearColor(0, 0, 0, 0);\n    gl.clear(gl.COLOR_BUFFER_BIT);\n    gl.uniform2f(uRes, canvas.width, canvas.height);\n    gl.uniform1f(uTime, (performance.now() - startedAt) / MS_PER_SECOND);\n    gl.uniform1f(uBands, settings.bands);\n    gl.uniform1f(uSpeed, settings.speed);\n    gl.uniform1f(uBlur, settings.blur);\n    gl.uniform1f(uIntensity, settings.intensity);\n    gl.uniform1f(uDirection, settings.direction);\n    gl.uniform1f(uGrain, settings.grain);\n    gl.uniform1f(uSeed, grainSeed);\n    gl.uniform1f(uPixel, pixelRatio);\n    gl.uniform1i(uColorCount, colorCount);\n    gl.uniform3fv(uColors, palette);\n    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n  };\n\n  const tick = () => {\n    if (destroyed || !running) {\n      return;\n    }\n    draw();\n    frame = requestAnimationFrame(tick);\n  };\n\n  resize();\n\n  return {\n    destroy: () => {\n      destroyed = true;\n      running = false;\n      cancelAnimationFrame(frame);\n      if (buffer) {\n        gl.deleteBuffer(buffer);\n      }\n      gl.deleteProgram(program);\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n    },\n    render: () => {\n      resize();\n      draw();\n    },\n    resize: () => {\n      resize();\n      if (!running) {\n        draw();\n      }\n    },\n    setRunning: (next: boolean) => {\n      if (destroyed || running === next) {\n        return;\n      }\n      running = next;\n      if (next) {\n        frame = requestAnimationFrame(tick);\n      } else {\n        cancelAnimationFrame(frame);\n      }\n    },\n    setSettings: (next: AuroraSettings) => {\n      settings = next;\n      applyPalette();\n    },\n  };\n};\n\nconst AuroraCurtain = ({\n  bands = DEFAULT_BANDS,\n  blur = DEFAULT_BLUR,\n  children,\n  className,\n  colors = DEFAULT_COLORS,\n  direction = \"up\",\n  intensity = DEFAULT_INTENSITY,\n  noise = 0,\n  paused = false,\n  speed = DEFAULT_SPEED,\n}: AuroraCurtainProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const hostRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const controllerRef = useRef<AuroraController | null>(null);\n  const [isSupported, setIsSupported] = useState(true);\n  const [isActive, setIsActive] = useState(false);\n  const [resolvedColors, setResolvedColors] = useState<Rgb[]>(FALLBACK_COLORS);\n\n  const colorKey = colors.join(\"|\");\n  const grain = resolveGrain(noise);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) {\n      return;\n    }\n    setResolvedColors(resolveCssColors(colorKey.split(\"|\"), host));\n  }, [colorKey]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n    const controller = createAuroraController(canvas);\n    controllerRef.current = controller;\n    setIsSupported(controller !== null);\n    if (!controller) {\n      return;\n    }\n\n    const observer = new ResizeObserver(() => controller.resize());\n    observer.observe(canvas);\n\n    return () => {\n      observer.disconnect();\n      controller.destroy();\n      controllerRef.current = null;\n    };\n  }, []);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!(host && isSupported)) {\n      return;\n    }\n\n    let isOnScreen = true;\n    const sync = () => {\n      setIsActive(isOnScreen && document.visibilityState === \"visible\");\n    };\n\n    const observer = new IntersectionObserver((entries) => {\n      isOnScreen = entries.some((entry) => entry.isIntersecting);\n      sync();\n    });\n    observer.observe(host);\n    document.addEventListener(\"visibilitychange\", sync);\n    sync();\n\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n    };\n  }, [isSupported]);\n\n  useEffect(() => {\n    const controller = controllerRef.current;\n    if (!controller) {\n      return;\n    }\n    controller.setSettings({\n      bands: clamp(Math.round(bands), MIN_BANDS, MAX_BANDS),\n      blur: clamp(blur, 0, 1),\n      colors: resolvedColors,\n      direction: direction === \"down\" ? 1 : 0,\n      grain,\n      intensity: clamp(intensity, 0, 1),\n      speed: Math.max(speed, 0),\n    });\n    controller.render();\n  }, [bands, blur, direction, grain, intensity, resolvedColors, speed]);\n\n  useEffect(() => {\n    const controller = controllerRef.current;\n    if (!controller) {\n      return;\n    }\n    const shouldRun = isActive && !paused && !shouldReduceMotion;\n    controller.setRunning(shouldRun);\n    if (!shouldRun) {\n      controller.render();\n    }\n  }, [isActive, paused, shouldReduceMotion]);\n\n  const fallbackBackground = colors\n    .map((color, index) => {\n      const x = 14 + index * (72 / Math.max(colors.length, 1));\n      const y = direction === \"up\" ? 108 : -8;\n      return `radial-gradient(58% 96% at ${x}% ${y}%, ${toCssColor(color)} 0%, transparent 68%)`;\n    })\n    .join(\", \");\n\n  return (\n    <div\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      ref={hostRef}\n    >\n      {isSupported ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n        >\n          <canvas className=\"h-full w-full\" ref={canvasRef} />\n        </div>\n      ) : (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 opacity-70 blur-2xl\"\n          style={{ backgroundImage: fallbackBackground }}\n        />\n      )}\n      <div className=\"relative z-10\">{children}</div>\n    </div>\n  );\n};\n\nexport default AuroraCurtain;\n","path":"index.tsx","target":"components/smoothui/aurora-curtain/index.tsx","type":"registry:ui"}],"name":"aurora-curtain","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Aurora Curtain","type":"registry:ui"}