diff --git a/README.md b/README.md
index d885b7c..8cf3f0a 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,7 @@ pnpm dlx shadcn@latest add radiumcoders/23rd.dev/gooey-color-picker
| [Radiant Lines](https://23rd.dev/docs/components/radiant-lines) | `@23rd/radiant-lines` | Hyperspace starfield background; warp speed driven by scroll |
| [Shader Fire](https://23rd.dev/docs/components/shader-fire) | `@23rd/shader-fire` | Sparse 2D fire wash — tongues rise from the bottom behind a landing hero |
| [Shader Gradient](https://23rd.dev/docs/components/shader-gradient) | `@23rd/shader-gradient` | Quiet WebGL wash behind landing heroes, empty states, and marketing sections |
+| [Shader Sky](https://23rd.dev/docs/components/shader-sky) | `@23rd/shader-sky` | Clear blue or rain behind a hero — drifting clouds, optional window-glass film |
| [Stretchy Footer](https://23rd.dev/docs/components/stretchy-footer) | `@23rd/stretchy-footer` | Dia-style rubber overscroll; aurora stretches past the bottom, then snaps back |
| [Tangle Footer](https://23rd.dev/docs/components/tangle-footer) | `@23rd/tangle-footer` | Nested SVG text ribbons as a footer |
diff --git a/app/globals.css b/app/globals.css
index f01ff0c..a90d256 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -99,6 +99,7 @@
}
:root {
+ color-scheme: light;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -134,6 +135,7 @@
}
.dark {
+ color-scheme: dark;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
@@ -167,6 +169,61 @@
--sidebar-ring: oklch(0.556 0 0);
}
+/* Before `html.dark` exists, :root is light. Keep system-dark users on dark
+ tokens so a late theme script cannot flash white. `.light` still wins. */
+@media (prefers-color-scheme: dark) {
+ :root:not(.light) {
+ color-scheme: dark;
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+ --color-fd-background: hsl(0, 0%, 7.04%);
+ --color-fd-foreground: hsl(0, 0%, 92%);
+ --color-fd-muted: hsl(0, 0%, 12.9%);
+ --color-fd-muted-foreground: hsla(0, 0%, 70%, 0.8);
+ --color-fd-popover: hsl(0, 0%, 11.6%);
+ --color-fd-popover-foreground: hsl(0, 0%, 86.9%);
+ --color-fd-card: hsl(0, 0%, 9.8%);
+ --color-fd-card-foreground: hsl(0, 0%, 98%);
+ --color-fd-border: hsla(0, 0%, 40%, 20%);
+ --color-fd-primary: hsl(0, 0%, 98%);
+ --color-fd-primary-foreground: hsl(0, 0%, 9%);
+ --color-fd-secondary: hsl(0, 0%, 12.9%);
+ --color-fd-secondary-foreground: hsl(0, 0%, 92%);
+ --color-fd-accent: hsla(0, 0%, 40.9%, 30%);
+ --color-fd-accent-foreground: hsl(0, 0%, 90%);
+ --color-fd-ring: hsl(0, 0%, 54.9%);
+ }
+}
+
@layer base {
* {
@apply border-border outline-ring/50;
@@ -210,7 +267,7 @@
}
html {
- @apply font-sans;
+ @apply bg-background font-sans;
scrollbar-gutter: stable;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 88cf4da..9767b39 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -8,6 +8,7 @@ import { DocsSearchDialog } from "@/components/docs-search-dialog"
import { JsonLd } from "@/components/json-ld"
import { ThemeProvider } from "@/components/theme-provider"
import { TooltipProvider } from "@/components/ui/tooltip"
+import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap"
import { Toaster } from "sonner"
import {
OG_IMAGE_SIZE,
@@ -121,10 +122,16 @@ export default function RootLayout({
suppressHydrationWarning
className={cn("font-sans antialiased", fontMono.variable, geist.variable)}
>
+
+
+
{
const next = colors.map((c, i) =>
- i === index ? e.target.value.toUpperCase() : c
+ i === index ? toHex6(e.target.value) : c
)
onChange(next)
}}
diff --git a/components/search-trigger.tsx b/components/search-trigger.tsx
index a78d1bf..a4899e0 100644
--- a/components/search-trigger.tsx
+++ b/components/search-trigger.tsx
@@ -1,11 +1,17 @@
"use client"
+import { useEffect, useState } from "react"
import { Dialog } from "@base-ui/react/dialog"
import { useSearchContext } from "fumadocs-ui/contexts/search"
import { RiSearchLine } from "@remixicon/react"
export function SearchTrigger() {
const { enabled, dialogHandle, hotKey } = useSearchContext()
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
if (!enabled) return null
@@ -18,16 +24,20 @@ export function SearchTrigger() {
>
Search
-
- {hotKey.map((k, i) => (
-
- {k.display}
-
- ))}
-
+ {mounted ? (
+
+ {hotKey.map((k, i) => (
+
+ {k.display}
+
+ ))}
+
+ ) : (
+
+ )}
)
}
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
index a4abf4c..331213b 100644
--- a/components/theme-provider.tsx
+++ b/components/theme-provider.tsx
@@ -3,9 +3,9 @@
import * as React from "react"
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
-// next-themes injects an inline
+
+
+
+
+
+ New
+
+
+ The sky is the theme
+
+
+ Clear in light. Rain in dark. The shader stays behind the copy.
+
+
+
+```
+
+
+
+Force a forecast if the parent theme should not drive it:
+
+
+
+ ```tsx
+
+
+ ```
+
+
+ ```svelte
+
+
+ ```
+
+
+
+See the same sky through window glass — a transparent dotted film, sky still moving underneath:
+
+
+
+ ```tsx
+
+ ```
+
+
+ ```svelte
+
+ ```
+
+
+
+Fewer, smaller clouds with a mix of sizes:
+
+
+
+ ```tsx
+
+ ```
+
+
+ ```svelte
+
+ ```
+
+
+
+## Props
+
+| Prop | Type | Default |
+| ------------- | ----------------------------- | -------------------------------- |
+| `colors` | `string[]` | zenith / horizon / cloud / shade |
+| `speed` | `number` | `0.1` |
+| `coverage` | `number` | `0.5` |
+| `intensity` | `number` | `0.9` |
+| `amount` | `number` | `0.5` |
+| `scale` | `number` | `0.4` |
+| `variation` | `number` | `0.7` |
+| `interactive` | `boolean` | `false` |
+| `glass` | `boolean` | `false` |
+| `glassSize` | `number` | `7` |
+| `theme` | `"light" \| "dark" \| "auto"` | `auto` |
+| `className` | `string` | React only |
+| `class` | `string` | Svelte only |
diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx
index c680e44..ac841f8 100644
--- a/content/docs/getting-started.mdx
+++ b/content/docs/getting-started.mdx
@@ -13,7 +13,7 @@ Or pull straight from the public GitHub source registry:
-Swap `shader-gradient` for any item name — `shader-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `phosphor-score`, `radiant-lines`, `dithered-404`, `tangle-footer`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve.
+Swap `shader-gradient` for any item name — `shader-sky`, `shader-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `phosphor-score`, `radiant-lines`, `dithered-404`, `tangle-footer`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve.
## Use a background or shader
@@ -50,14 +50,14 @@ export function Hero() {
-Shaders such as [Shader Gradient](/docs/components/shader-gradient) and [Shader Fire](/docs/components/shader-fire) are atmosphere, not the page.
+Shaders such as [Shader Gradient](/docs/components/shader-gradient), [Shader Fire](/docs/components/shader-fire), and [Shader Sky](/docs/components/shader-sky) are atmosphere, not the page.
## Browse
| Category | Components |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Background | [Logo Burst](/docs/components/logo-burst), [Phosphor Score](/docs/components/phosphor-score), [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid) |
-| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Fire](/docs/components/shader-fire) |
+| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Fire](/docs/components/shader-fire), [Shader Sky](/docs/components/shader-sky) |
| Footers | [Tangle Footer](/docs/components/tangle-footer), [Stretchy Footer](/docs/components/stretchy-footer) |
| Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) |
| Pages | [Dithered 404](/docs/components/dithered-404) |
diff --git a/content/docs/index.mdx b/content/docs/index.mdx
index 3df1510..1ad7f58 100644
--- a/content/docs/index.mdx
+++ b/content/docs/index.mdx
@@ -21,6 +21,7 @@ Most component kits hand you every option. 23rd picks a direction: spacing, radi
- [ASCII Logo](/docs/components/ascii-logo) — interactive ASCII wordmark that scatters and falls
- [Shader Gradient](/docs/components/shader-gradient) — quiet wash behind a landing hero
- [Shader Fire](/docs/components/shader-fire) — sparse fire tongues under a landing hero
+- [Shader Sky](/docs/components/shader-sky) — clear blue or rain, optional window glass
- [Logo Burst](/docs/components/logo-burst) — hair-line tentacles explode from the center, then breathe
- [Phosphor Score](/docs/components/phosphor-score) — vertical CRT sheet music that falls and flares
- [Radiant Lines](/docs/components/radiant-lines) — hyperspace starfield background
diff --git a/lib/theme-bootstrap.ts b/lib/theme-bootstrap.ts
new file mode 100644
index 0000000..5eee77b
--- /dev/null
+++ b/lib/theme-bootstrap.ts
@@ -0,0 +1,8 @@
+/**
+ * Blocking `` snippet. Must stay in sync with `ThemeProvider`:
+ * `attribute="class"`, `storageKey="theme"`, `defaultTheme="system"`, `enableSystem`.
+ *
+ * next-themes injects the same logic from a client component inside ``.
+ * React 19 / streaming can paint `:root` light tokens before that script runs.
+ */
+export const THEME_BOOTSTRAP_SCRIPT = `(function(){try{var d=document.documentElement,c=d.classList;c.remove("light","dark");var t=localStorage.getItem("theme");if(t==="system"||!t){t=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}c.add(t);if(t==="light"||t==="dark")d.style.colorScheme=t}catch(e){}})();`
diff --git a/public/r/registry.json b/public/r/registry.json
index d26f4ef..11e7949 100644
--- a/public/r/registry.json
+++ b/public/r/registry.json
@@ -295,6 +295,32 @@
],
"type": "registry:ui"
},
+ {
+ "name": "shader-sky",
+ "title": "Shader Sky",
+ "description": "WebGL sky for landing heroes — clear blue with drifting clouds in light, storm gray in dark, optional window-glass film.",
+ "files": [
+ {
+ "path": "registry/shader-sky/shader-sky.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/shader-sky.tsx"
+ }
+ ],
+ "type": "registry:ui"
+ },
+ {
+ "name": "shader-sky-svelte",
+ "title": "Shader Sky",
+ "description": "WebGL sky for landing heroes — clear blue with drifting clouds in light, storm gray in dark, optional window-glass film.",
+ "files": [
+ {
+ "path": "registry/shader-sky/shader-sky.svelte",
+ "type": "registry:file",
+ "target": "src/lib/components/ui/shader-sky.svelte"
+ }
+ ],
+ "type": "registry:ui"
+ },
{
"name": "stretchy-footer",
"title": "Stretchy Footer",
diff --git a/public/r/shader-sky-svelte.json b/public/r/shader-sky-svelte.json
new file mode 100644
index 0000000..1077f7d
--- /dev/null
+++ b/public/r/shader-sky-svelte.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "shader-sky-svelte",
+ "title": "Shader Sky",
+ "description": "WebGL sky for landing heroes — clear blue with drifting clouds in light, storm gray in dark, optional window-glass film.",
+ "files": [
+ {
+ "path": "registry/shader-sky/shader-sky.svelte",
+ "content": "\n\n\n\n\n \n
\n",
+ "type": "registry:file",
+ "target": "src/lib/components/ui/shader-sky.svelte"
+ }
+ ],
+ "type": "registry:ui"
+}
\ No newline at end of file
diff --git a/public/r/shader-sky.json b/public/r/shader-sky.json
new file mode 100644
index 0000000..207ee10
--- /dev/null
+++ b/public/r/shader-sky.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "shader-sky",
+ "title": "Shader Sky",
+ "description": "WebGL sky for landing heroes — clear blue with drifting clouds in light, storm gray in dark, optional window-glass film.",
+ "files": [
+ {
+ "path": "registry/shader-sky/shader-sky.tsx",
+ "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ShaderSkyTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type ShaderSkyOptions = {\n /** Zenith / horizon / cloud / shade hex */\n colors?: string[]\n /** Drift speed. Default 0.1 */\n speed?: number\n /** How thick the clouds read 0–1. Default 0.5 */\n coverage?: number\n /** Cloud strength 0–1. Default 0.9 */\n intensity?: number\n /** How many distinct clouds 0–1. Default 0.5 */\n amount?: number\n /** Cloud size 0–1. Default 0.4 */\n scale?: number\n /** Mix of big and small clouds 0–1. Default 0.7 */\n variation?: number\n /** Follow the pointer gently. Default false */\n interactive?: boolean\n /**\n * Window glass — a transparent dotted film over the sky.\n * Default false\n */\n glass?: boolean\n /** Glass texture cell size in CSS px. Default `7` */\n glassSize?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class) **only when `colors` is omitted**, so the stock\n * clear-sky / rain palettes swap with the site theme. A custom palette\n * stays put.\n */\n theme?: ShaderSkyTheme\n /** Fires whenever resolved dark mode changes (CSS fallback). */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type ShaderSkyInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Open daylight — zenith / horizon / white smoke / cool shade */\nexport const LIGHT_COLORS = [\"#2478C8\", \"#8ECBF2\", \"#F7FBFF\", \"#C5D8EC\"]\n/** Storm ceiling — light slate / rain horizon / mid cloud / cool shade */\nexport const DARK_COLORS = [\"#9AA3AD\", \"#C8CED4\", \"#5C6570\", \"#3F4750\"]\n\nexport const LIGHT_FALLBACK = {\n backgroundColor: \"#5BA3DC\",\n backgroundImage: [\n \"linear-gradient(180deg, #2478C8 0%, #5BA3DC 48%, #8ECBF2 100%)\",\n \"radial-gradient(28% 18% at 22% 68%, #F7FBFF 0%, transparent 70%)\",\n \"radial-gradient(24% 16% at 74% 42%, #F7FBFF 0%, transparent 68%)\",\n \"radial-gradient(20% 14% at 88% 76%, #C5D8EC 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport const DARK_FALLBACK = {\n backgroundColor: \"#A8B0B8\",\n backgroundImage: [\n \"linear-gradient(180deg, #9AA3AD 0%, #B0B7BF 46%, #C8CED4 100%)\",\n \"radial-gradient(32% 22% at 24% 62%, #5C6570 0%, transparent 70%)\",\n \"radial-gradient(26% 18% at 76% 34%, #3F4750 0%, transparent 68%)\",\n \"radial-gradient(22% 16% at 58% 80%, #5C6570 0%, transparent 70%)\",\n ].join(\", \"),\n} as const\n\nexport function skyFallback(colors: string[] | undefined, dark: boolean) {\n if (colors && colors.length > 0) {\n const c1 = colors[0] ?? LIGHT_COLORS[0]!\n const c2 = colors[1] ?? LIGHT_COLORS[1]!\n const c3 = colors[2] ?? LIGHT_COLORS[2]!\n const c4 = colors[3] ?? LIGHT_COLORS[3]!\n return {\n backgroundColor: c2,\n backgroundImage: [\n `linear-gradient(180deg, ${c1} 0%, ${c2} 100%)`,\n `radial-gradient(28% 18% at 22% 68%, ${c3} 0%, transparent 70%)`,\n `radial-gradient(24% 16% at 74% 42%, ${c3} 0%, transparent 68%)`,\n `radial-gradient(20% 14% at 88% 76%, ${c4} 0%, transparent 70%)`,\n ].join(\", \"),\n }\n }\n return dark ? DARK_FALLBACK : LIGHT_FALLBACK\n}\n\nconst VERT = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAG = `\nprecision highp float;\n\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform float u_speed;\nuniform float u_coverage;\nuniform float u_intensity;\nuniform float u_amount;\nuniform float u_scale;\nuniform float u_variation;\nuniform float u_glass;\nuniform float u_glassSize;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec3 u_c4;\nuniform vec2 u_mouse;\n\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;\n}\n\nfloat fbm(vec2 p) {\n float v = 0.0;\n float a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 4; i++) {\n v += a * noise(p);\n p = m * p;\n a *= 0.5;\n }\n return v;\n}\n\nvec2 skyField(vec2 uv, float aspect, float t) {\n vec2 st = vec2(uv.x * aspect, uv.y);\n st -= (u_mouse - 0.5) * 0.04;\n\n float amount = clamp(u_amount, 0.0, 1.0);\n float scale = clamp(u_scale, 0.0, 1.0);\n float variation = clamp(u_variation, 0.0, 1.0);\n float cover = clamp(u_coverage, 0.0, 1.0);\n\n float freq = mix(2.65, 1.05, scale);\n vec2 wind = vec2(t * 0.18, t * 0.012);\n vec2 p = vec2(st.x, st.y * 1.18) * freq + vec2(wind.x, 0.0);\n\n vec2 q = vec2(\n fbm(p + vec2(0.0, wind.y)),\n fbm(p + vec2(5.2, 1.3) - wind.yx)\n );\n vec2 r = vec2(\n fbm(p + q * 1.35 + vec2(1.7, 9.2) + wind * 0.4),\n fbm(p + q * 1.35 + vec2(8.3, 2.8) - wind * 0.28)\n );\n\n vec2 base = p * 0.58 + r * 1.15 + wind * 0.2;\n float big = fbm(base);\n float small = fbm(p * mix(2.2, 1.35, scale) + q * 0.7 + vec2(t * 0.26, 0.1));\n float n = mix(big, mix(big, small, 0.55), variation);\n n = n * n * (3.0 - 2.0 * n);\n\n float lo = mix(0.42, 0.24, amount) - (cover - 0.5) * 0.10;\n float hi = lo + mix(0.20, 0.13, amount);\n float density = smoothstep(lo, hi, n);\n\n float far = fbm(st * freq * 0.38 + vec2(t * 0.09, 0.3) + 3.1);\n far = far * far * (3.0 - 2.0 * far);\n float farC = smoothstep(lo - 0.04, hi - 0.02, far) * mix(0.16, 0.42, amount);\n density = clamp(density + farC * (1.0 - density * 0.4), 0.0, 1.0);\n density *= mix(0.78, 1.14, cover);\n\n // Sun from upper-right: denser along the light ray = self-shadow.\n vec2 sun = vec2(0.22, 0.28);\n float nLit = fbm(base + sun);\n nLit = nLit * nLit * (3.0 - 2.0 * nLit);\n float lift = clamp((n - nLit) * 2.6, -1.0, 1.0);\n\n float mottling = fbm(p * 3.15 + q * 0.35 + vec2(2.4, -1.1));\n float under = smoothstep(0.22, 0.86, n) * (1.0 - st.y * 0.48);\n float shade = 0.40 + lift * 0.50 - under * 0.30;\n shade = mix(shade, shade * mix(0.58, 1.32, mottling), 0.52);\n shade = clamp(shade, 0.0, 1.0);\n\n return vec2(clamp(density, 0.0, 1.0), shade);\n}\n\nvoid main() {\n float aspect = u_resolution.x / max(u_resolution.y, 1.0);\n float t = u_time * u_speed;\n float cell = max(u_glassSize, 2.0);\n\n vec2 uv = gl_FragCoord.xy / u_resolution.xy;\n vec2 glassLocal = fract(gl_FragCoord.xy / cell) - 0.5;\n if (u_glass > 0.5) {\n uv += glassLocal * 0.0032;\n }\n\n vec2 field = skyField(uv, aspect, t);\n float density = field.x;\n float shade = field.y;\n\n float h = pow(clamp(uv.y, 0.0, 1.0), 0.76);\n vec3 sky = mix(u_c2, u_c1, h);\n\n vec3 belly = mix(u_c4, u_c1, 0.32) * 0.76;\n vec3 body = mix(u_c4, u_c3, 0.58);\n vec3 top = mix(u_c3, vec3(1.0), 0.52);\n vec3 lit = mix(belly, body, smoothstep(0.12, 0.58, shade));\n lit = mix(lit, top, smoothstep(0.55, 0.98, shade) * 0.72);\n lit = mix(lit, belly, (1.0 - shade) * 0.22 * density);\n\n float alpha = density * mix(0.55, 1.12, clamp(u_intensity, 0.0, 1.0));\n alpha = clamp(alpha, 0.0, 1.0);\n vec3 col = mix(sky, lit, alpha);\n\n if (u_glass > 0.5) {\n float pane = smoothstep(0.50, 0.22, length(glassLocal));\n col *= mix(0.93, 1.0, pane);\n vec2 hl = glassLocal - vec2(-0.16, 0.18);\n col += exp(-dot(hl, hl) * 55.0) * 0.035;\n col = mix(col, vec3(dot(col, vec3(0.299, 0.587, 0.114))), 0.04);\n col += (hash(gl_FragCoord.xy) - 0.5) * 0.008;\n } else {\n col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.008;\n }\n\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`\n\nfunction isDev() {\n return (\n typeof process !== \"undefined\" && process.env?.NODE_ENV !== \"production\"\n )\n}\n\nfunction hexToRgb(hex: string): [number, number, number] {\n const h = hex.replace(\"#\", \"\").trim()\n const full =\n h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h.padEnd(6, \"0\").slice(0, 6)\n const n = Number.parseInt(full, 16)\n if (Number.isNaN(n)) return [0.14, 0.47, 0.78]\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: ShaderSkyTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n if (!shader) return null\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderSky: shader failed to compile\\n\",\n gl.getShaderInfoLog(shader)\n )\n }\n gl.deleteShader(shader)\n return null\n }\n return shader\n}\n\n/**\n * WebGL sky for heroes — clear blue with drifting clouds in light,\n * storm gray in dark. Optional window-glass film.\n */\nexport function createShaderSky(\n canvas: HTMLCanvasElement,\n initial: ShaderSkyOptions = {}\n): ShaderSkyInstance | null {\n let options: Required<\n Pick<\n ShaderSkyOptions,\n | \"speed\"\n | \"coverage\"\n | \"intensity\"\n | \"amount\"\n | \"scale\"\n | \"variation\"\n | \"interactive\"\n | \"glass\"\n | \"glassSize\"\n | \"theme\"\n >\n > &\n ShaderSkyOptions = {\n speed: 0.1,\n coverage: 0.5,\n intensity: 0.9,\n amount: 0.5,\n scale: 0.4,\n variation: 0.7,\n interactive: false,\n glass: false,\n glassSize: 7,\n theme: \"auto\",\n ...initial,\n }\n\n const mouse = { x: 0.5, y: 0.5 }\n const targetMouse = { x: 0.5, y: 0.5 }\n let reduce = false\n let dark = resolveDark(options.theme ?? \"auto\")\n options.onThemeChange?.(dark)\n\n const gl = canvas.getContext(\"webgl\", {\n alpha: false,\n antialias: false,\n depth: false,\n stencil: false,\n powerPreference: \"high-performance\",\n })\n if (!gl) return null\n\n const vs = compile(gl, gl.VERTEX_SHADER, VERT)\n const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)\n if (!vs || !fs) {\n if (vs) gl.deleteShader(vs)\n if (fs) gl.deleteShader(fs)\n return null\n }\n\n const program = gl.createProgram()\n if (!program) {\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.attachShader(program, vs)\n gl.attachShader(program, fs)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n if (isDev()) {\n console.warn(\n \"ShaderSky: program failed to link\\n\",\n gl.getProgramInfoLog(program)\n )\n }\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n return null\n }\n gl.useProgram(program)\n\n const buf = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buf)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW\n )\n const loc = gl.getAttribLocation(program, \"a_position\")\n gl.enableVertexAttribArray(loc)\n gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)\n\n const uResolution = gl.getUniformLocation(program, \"u_resolution\")\n const uTime = gl.getUniformLocation(program, \"u_time\")\n const uSpeed = gl.getUniformLocation(program, \"u_speed\")\n const uCoverage = gl.getUniformLocation(program, \"u_coverage\")\n const uIntensity = gl.getUniformLocation(program, \"u_intensity\")\n const uAmount = gl.getUniformLocation(program, \"u_amount\")\n const uScale = gl.getUniformLocation(program, \"u_scale\")\n const uVariation = gl.getUniformLocation(program, \"u_variation\")\n const uGlass = gl.getUniformLocation(program, \"u_glass\")\n const uGlassSize = gl.getUniformLocation(program, \"u_glassSize\")\n const uC1 = gl.getUniformLocation(program, \"u_c1\")\n const uC2 = gl.getUniformLocation(program, \"u_c2\")\n const uC3 = gl.getUniformLocation(program, \"u_c3\")\n const uC4 = gl.getUniformLocation(program, \"u_c4\")\n const uMouse = gl.getUniformLocation(program, \"u_mouse\")\n\n let raf = 0\n let running = true\n const start = performance.now()\n let frozenTime = 0\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n if (w <= 0 || h <= 0) return\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n gl.viewport(0, 0, canvas.width, canvas.height)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const onMove = (e: PointerEvent) => {\n if (!options.interactive) return\n const parent = canvas.parentElement\n if (!parent) return\n const rect = parent.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n targetMouse.x = (e.clientX - rect.left) / rect.width\n targetMouse.y = 1 - (e.clientY - rect.top) / rect.height\n }\n window.addEventListener(\"pointermove\", onMove, { passive: true })\n\n const tick = (now: number) => {\n if (!running) return\n\n if (reduce) {\n if (frozenTime === 0) frozenTime = (now - start) / 1000\n } else {\n frozenTime = 0\n }\n\n const time = reduce ? frozenTime : (now - start) / 1000\n const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)\n const palette = [0, 1, 2, 3].map((i) =>\n hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!)\n )\n\n mouse.x += (targetMouse.x - mouse.x) * 0.03\n mouse.y += (targetMouse.y - mouse.y) * 0.03\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const glassPx = Math.max(2, options.glassSize ?? 7) * dpr\n\n gl.uniform2f(uResolution, canvas.width, canvas.height)\n gl.uniform1f(uTime, time)\n gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.1))\n gl.uniform1f(uCoverage, Math.min(1, Math.max(0, options.coverage ?? 0.5)))\n gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.9)))\n gl.uniform1f(uAmount, Math.min(1, Math.max(0, options.amount ?? 0.5)))\n gl.uniform1f(uScale, Math.min(1, Math.max(0, options.scale ?? 0.4)))\n gl.uniform1f(uVariation, Math.min(1, Math.max(0, options.variation ?? 0.7)))\n gl.uniform1f(uGlass, options.glass ? 1 : 0)\n gl.uniform1f(uGlassSize, glassPx)\n gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2])\n gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2])\n gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2])\n gl.uniform3f(uC4, palette[3]![0], palette[3]![1], palette[3]![2])\n gl.uniform2f(\n uMouse,\n options.interactive ? mouse.x : 0.5,\n options.interactive ? mouse.y : 0.5\n )\n\n gl.drawArrays(gl.TRIANGLES, 0, 6)\n raf = requestAnimationFrame(tick)\n }\n\n raf = requestAnimationFrame(tick)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n syncTheme()\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onMove)\n gl.deleteProgram(program)\n gl.deleteShader(vs)\n gl.deleteShader(fs)\n gl.deleteBuffer(buf)\n },\n }\n}\n\nexport type ShaderSkyProps = Omit & {\n className?: string\n}\n\n/**\n * WebGL sky for heroes — clear blue with drifting clouds in light,\n * storm gray in dark. Optional window-glass film.\n */\nexport function ShaderSky({\n className,\n colors,\n speed = 0.1,\n coverage = 0.5,\n intensity = 0.9,\n amount = 0.5,\n scale = 0.4,\n variation = 0.7,\n interactive = false,\n glass = false,\n glassSize = 7,\n theme = \"auto\",\n}: ShaderSkyProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createShaderSky(canvas, {\n colors,\n speed,\n coverage,\n intensity,\n amount,\n scale,\n variation,\n interactive,\n glass,\n glassSize,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n colors,\n speed,\n coverage,\n intensity,\n amount,\n scale,\n variation,\n interactive,\n glass,\n glassSize,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n colors,\n speed,\n coverage,\n intensity,\n amount,\n scale,\n variation,\n interactive,\n glass,\n glassSize,\n theme,\n ])\n\n const fallback = skyFallback(colors, isDark)\n\n return (\n \n \n
\n )\n}\n",
+ "type": "registry:ui",
+ "target": "components/ui/shader-sky.tsx"
+ }
+ ],
+ "type": "registry:ui"
+}
\ No newline at end of file
diff --git a/registry.json b/registry.json
index bd8a0c2..306b41f 100644
--- a/registry.json
+++ b/registry.json
@@ -14,6 +14,7 @@
"registry/radiant-lines/registry.json",
"registry/shader-fire/registry.json",
"registry/shader-gradient/registry.json",
+ "registry/shader-sky/registry.json",
"registry/stretchy-footer/registry.json",
"registry/tangle-footer/registry.json"
]
diff --git a/registry/shader-sky/registry.json b/registry/shader-sky/registry.json
new file mode 100644
index 0000000..f114ef4
--- /dev/null
+++ b/registry/shader-sky/registry.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
+ "items": [
+ {
+ "name": "shader-sky",
+ "type": "registry:ui",
+ "title": "Shader Sky",
+ "description": "WebGL sky for landing heroes — clear blue with drifting clouds in light, storm gray in dark, optional window-glass film.",
+ "files": [
+ {
+ "path": "shader-sky.tsx",
+ "type": "registry:ui",
+ "target": "components/ui/shader-sky.tsx"
+ }
+ ]
+ },
+ {
+ "name": "shader-sky-svelte",
+ "type": "registry:ui",
+ "title": "Shader Sky",
+ "description": "WebGL sky for landing heroes — clear blue with drifting clouds in light, storm gray in dark, optional window-glass film.",
+ "files": [
+ {
+ "path": "shader-sky.svelte",
+ "type": "registry:file",
+ "target": "src/lib/components/ui/shader-sky.svelte"
+ }
+ ]
+ }
+ ]
+}
diff --git a/registry/shader-sky/shader-sky-demo.tsx b/registry/shader-sky/shader-sky-demo.tsx
new file mode 100644
index 0000000..0cec085
--- /dev/null
+++ b/registry/shader-sky/shader-sky-demo.tsx
@@ -0,0 +1,190 @@
+"use client"
+
+import { Button } from "@/components/ui/button"
+import {
+ ComponentControls,
+ ControlColors,
+ ControlSlider,
+ ControlSwitch,
+} from "@/components/component-controls"
+import { ComponentPreview } from "@/components/component-preview"
+import { usePreviewProps } from "@/hooks/use-preview-props"
+import { ShaderSky } from "@/registry/shader-sky/shader-sky"
+
+const SKY_PALETTES = [
+ ["#1D4E89", "#7EB6D9", "#F4F8FC", "#B7D0E6"],
+ ["#5C6B78", "#9AA4AB", "#D0D5DA", "#6A7380"],
+ ["#C45C26", "#F0A868", "#FFF6E8", "#E8C9A0"],
+]
+
+const DEFAULTS = {
+ speed: 0.1,
+ coverage: 0.5,
+ intensity: 0.9,
+ amount: 0.5,
+ scale: 0.4,
+ variation: 0.7,
+ interactive: false,
+ glass: false,
+ glassSize: 7,
+ overlay: false,
+ colors: SKY_PALETTES[0]!,
+}
+
+export function ShaderSkyDemo() {
+ const { props, updateProp, resetProps, hasChanges } = usePreviewProps(DEFAULTS)
+
+ return (
+ <>
+
+
+
+ {props.overlay ? (
+ <>
+
+
+
+ Weather
+
+
+ The sky is the theme
+
+
+ Clear blue in light. Storm gray in dark. Optional window
+ glass.
+
+
+
+
+
+
+ >
+ ) : null}
+
+
+
+
+ updateProp("colors", colors)}
+ />
+ updateProp("amount", v)}
+ />
+ updateProp("scale", v)}
+ />
+ updateProp("variation", v)}
+ />
+ updateProp("speed", v)}
+ />
+ updateProp("coverage", v)}
+ />
+ updateProp("intensity", v)}
+ />
+ updateProp("overlay", v)}
+ />
+ updateProp("interactive", v)}
+ />
+ updateProp("glass", v)}
+ />
+ updateProp("glassSize", v)}
+ />
+
+ >
+ )
+}
diff --git a/registry/shader-sky/shader-sky-vanilla.ts b/registry/shader-sky/shader-sky-vanilla.ts
new file mode 100644
index 0000000..9c79bf9
--- /dev/null
+++ b/registry/shader-sky/shader-sky-vanilla.ts
@@ -0,0 +1,524 @@
+export type ShaderSkyTheme = "light" | "dark" | "auto"
+
+export type ShaderSkyOptions = {
+ /** Zenith / horizon / cloud / shade hex */
+ colors?: string[]
+ /** Drift speed. Default 0.1 */
+ speed?: number
+ /** How thick the clouds read 0–1. Default 0.5 */
+ coverage?: number
+ /** Cloud strength 0–1. Default 0.9 */
+ intensity?: number
+ /** How many distinct clouds 0–1. Default 0.5 */
+ amount?: number
+ /** Cloud size 0–1. Default 0.4 */
+ scale?: number
+ /** Mix of big and small clouds 0–1. Default 0.7 */
+ variation?: number
+ /** Follow the pointer gently. Default false */
+ interactive?: boolean
+ /**
+ * Window glass — a transparent dotted film over the sky.
+ * Default false
+ */
+ glass?: boolean
+ /** Glass texture cell size in CSS px. Default `7` */
+ glassSize?: number
+ /**
+ * Palette mode. Default `auto` follows shadcn / next-themes
+ * (`html.dark` class) **only when `colors` is omitted**, so the stock
+ * clear-sky / rain palettes swap with the site theme. A custom palette
+ * stays put.
+ */
+ theme?: ShaderSkyTheme
+ /** Fires whenever resolved dark mode changes (CSS fallback). */
+ onThemeChange?: (dark: boolean) => void
+}
+
+export type ShaderSkyInstance = {
+ setOptions: (options: Partial) => void
+ destroy: () => void
+}
+
+/** Open daylight — zenith / horizon / white smoke / cool shade */
+export const LIGHT_COLORS = ["#2478C8", "#8ECBF2", "#F7FBFF", "#C5D8EC"]
+/** Storm ceiling — light slate / rain horizon / mid cloud / cool shade */
+export const DARK_COLORS = ["#9AA3AD", "#C8CED4", "#5C6570", "#3F4750"]
+
+export const LIGHT_FALLBACK = {
+ backgroundColor: "#5BA3DC",
+ backgroundImage: [
+ "linear-gradient(180deg, #2478C8 0%, #5BA3DC 48%, #8ECBF2 100%)",
+ "radial-gradient(28% 18% at 22% 68%, #F7FBFF 0%, transparent 70%)",
+ "radial-gradient(24% 16% at 74% 42%, #F7FBFF 0%, transparent 68%)",
+ "radial-gradient(20% 14% at 88% 76%, #C5D8EC 0%, transparent 70%)",
+ ].join(", "),
+} as const
+
+export const DARK_FALLBACK = {
+ backgroundColor: "#A8B0B8",
+ backgroundImage: [
+ "linear-gradient(180deg, #9AA3AD 0%, #B0B7BF 46%, #C8CED4 100%)",
+ "radial-gradient(32% 22% at 24% 62%, #5C6570 0%, transparent 70%)",
+ "radial-gradient(26% 18% at 76% 34%, #3F4750 0%, transparent 68%)",
+ "radial-gradient(22% 16% at 58% 80%, #5C6570 0%, transparent 70%)",
+ ].join(", "),
+} as const
+
+export function skyFallback(colors: string[] | undefined, dark: boolean) {
+ if (colors && colors.length > 0) {
+ const c1 = colors[0] ?? LIGHT_COLORS[0]!
+ const c2 = colors[1] ?? LIGHT_COLORS[1]!
+ const c3 = colors[2] ?? LIGHT_COLORS[2]!
+ const c4 = colors[3] ?? LIGHT_COLORS[3]!
+ return {
+ backgroundColor: c2,
+ backgroundImage: [
+ `linear-gradient(180deg, ${c1} 0%, ${c2} 100%)`,
+ `radial-gradient(28% 18% at 22% 68%, ${c3} 0%, transparent 70%)`,
+ `radial-gradient(24% 16% at 74% 42%, ${c3} 0%, transparent 68%)`,
+ `radial-gradient(20% 14% at 88% 76%, ${c4} 0%, transparent 70%)`,
+ ].join(", "),
+ }
+ }
+ return dark ? DARK_FALLBACK : LIGHT_FALLBACK
+}
+
+const VERT = `
+attribute vec2 a_position;
+void main() {
+ gl_Position = vec4(a_position, 0.0, 1.0);
+}
+`
+
+const FRAG = `
+precision highp float;
+
+uniform vec2 u_resolution;
+uniform float u_time;
+uniform float u_speed;
+uniform float u_coverage;
+uniform float u_intensity;
+uniform float u_amount;
+uniform float u_scale;
+uniform float u_variation;
+uniform float u_glass;
+uniform float u_glassSize;
+uniform vec3 u_c1;
+uniform vec3 u_c2;
+uniform vec3 u_c3;
+uniform vec3 u_c4;
+uniform vec2 u_mouse;
+
+float hash(vec2 p) {
+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
+}
+
+float noise(vec2 p) {
+ vec2 i = floor(p);
+ vec2 f = fract(p);
+ float a = hash(i);
+ float b = hash(i + vec2(1.0, 0.0));
+ float c = hash(i + vec2(0.0, 1.0));
+ float d = hash(i + vec2(1.0, 1.0));
+ vec2 u = f * f * (3.0 - 2.0 * f);
+ return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
+}
+
+float fbm(vec2 p) {
+ float v = 0.0;
+ float a = 0.5;
+ mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
+ for (int i = 0; i < 4; i++) {
+ v += a * noise(p);
+ p = m * p;
+ a *= 0.5;
+ }
+ return v;
+}
+
+vec2 skyField(vec2 uv, float aspect, float t) {
+ vec2 st = vec2(uv.x * aspect, uv.y);
+ st -= (u_mouse - 0.5) * 0.04;
+
+ float amount = clamp(u_amount, 0.0, 1.0);
+ float scale = clamp(u_scale, 0.0, 1.0);
+ float variation = clamp(u_variation, 0.0, 1.0);
+ float cover = clamp(u_coverage, 0.0, 1.0);
+
+ float freq = mix(2.65, 1.05, scale);
+ vec2 wind = vec2(t * 0.18, t * 0.012);
+ vec2 p = vec2(st.x, st.y * 1.18) * freq + vec2(wind.x, 0.0);
+
+ vec2 q = vec2(
+ fbm(p + vec2(0.0, wind.y)),
+ fbm(p + vec2(5.2, 1.3) - wind.yx)
+ );
+ vec2 r = vec2(
+ fbm(p + q * 1.35 + vec2(1.7, 9.2) + wind * 0.4),
+ fbm(p + q * 1.35 + vec2(8.3, 2.8) - wind * 0.28)
+ );
+
+ vec2 base = p * 0.58 + r * 1.15 + wind * 0.2;
+ float big = fbm(base);
+ float small = fbm(p * mix(2.2, 1.35, scale) + q * 0.7 + vec2(t * 0.26, 0.1));
+ float n = mix(big, mix(big, small, 0.55), variation);
+ n = n * n * (3.0 - 2.0 * n);
+
+ float lo = mix(0.42, 0.24, amount) - (cover - 0.5) * 0.10;
+ float hi = lo + mix(0.20, 0.13, amount);
+ float density = smoothstep(lo, hi, n);
+
+ float far = fbm(st * freq * 0.38 + vec2(t * 0.09, 0.3) + 3.1);
+ far = far * far * (3.0 - 2.0 * far);
+ float farC = smoothstep(lo - 0.04, hi - 0.02, far) * mix(0.16, 0.42, amount);
+ density = clamp(density + farC * (1.0 - density * 0.4), 0.0, 1.0);
+ density *= mix(0.78, 1.14, cover);
+
+ // Sun from upper-right: denser along the light ray = self-shadow.
+ vec2 sun = vec2(0.22, 0.28);
+ float nLit = fbm(base + sun);
+ nLit = nLit * nLit * (3.0 - 2.0 * nLit);
+ float lift = clamp((n - nLit) * 2.6, -1.0, 1.0);
+
+ float mottling = fbm(p * 3.15 + q * 0.35 + vec2(2.4, -1.1));
+ float under = smoothstep(0.22, 0.86, n) * (1.0 - st.y * 0.48);
+ float shade = 0.40 + lift * 0.50 - under * 0.30;
+ shade = mix(shade, shade * mix(0.58, 1.32, mottling), 0.52);
+ shade = clamp(shade, 0.0, 1.0);
+
+ return vec2(clamp(density, 0.0, 1.0), shade);
+}
+
+void main() {
+ float aspect = u_resolution.x / max(u_resolution.y, 1.0);
+ float t = u_time * u_speed;
+ float cell = max(u_glassSize, 2.0);
+
+ vec2 uv = gl_FragCoord.xy / u_resolution.xy;
+ vec2 glassLocal = fract(gl_FragCoord.xy / cell) - 0.5;
+ if (u_glass > 0.5) {
+ uv += glassLocal * 0.0032;
+ }
+
+ vec2 field = skyField(uv, aspect, t);
+ float density = field.x;
+ float shade = field.y;
+
+ float h = pow(clamp(uv.y, 0.0, 1.0), 0.76);
+ vec3 sky = mix(u_c2, u_c1, h);
+
+ vec3 belly = mix(u_c4, u_c1, 0.32) * 0.76;
+ vec3 body = mix(u_c4, u_c3, 0.58);
+ vec3 top = mix(u_c3, vec3(1.0), 0.52);
+ vec3 lit = mix(belly, body, smoothstep(0.12, 0.58, shade));
+ lit = mix(lit, top, smoothstep(0.55, 0.98, shade) * 0.72);
+ lit = mix(lit, belly, (1.0 - shade) * 0.22 * density);
+
+ float alpha = density * mix(0.55, 1.12, clamp(u_intensity, 0.0, 1.0));
+ alpha = clamp(alpha, 0.0, 1.0);
+ vec3 col = mix(sky, lit, alpha);
+
+ if (u_glass > 0.5) {
+ float pane = smoothstep(0.50, 0.22, length(glassLocal));
+ col *= mix(0.93, 1.0, pane);
+ vec2 hl = glassLocal - vec2(-0.16, 0.18);
+ col += exp(-dot(hl, hl) * 55.0) * 0.035;
+ col = mix(col, vec3(dot(col, vec3(0.299, 0.587, 0.114))), 0.04);
+ col += (hash(gl_FragCoord.xy) - 0.5) * 0.008;
+ } else {
+ col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.008;
+ }
+
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
+}
+`
+
+function isDev() {
+ return (
+ typeof process !== "undefined" && process.env?.NODE_ENV !== "production"
+ )
+}
+
+function hexToRgb(hex: string): [number, number, number] {
+ const h = hex.replace("#", "").trim()
+ const full =
+ h.length === 3
+ ? h
+ .split("")
+ .map((c) => c + c)
+ .join("")
+ : h.padEnd(6, "0").slice(0, 6)
+ const n = Number.parseInt(full, 16)
+ if (Number.isNaN(n)) return [0.14, 0.47, 0.78]
+ return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]
+}
+
+/** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */
+export function isDarkTheme(): boolean {
+ if (typeof document === "undefined") return false
+ const root = document.documentElement
+ if (root.classList.contains("dark")) return true
+ if (root.classList.contains("light")) return false
+ const dataTheme = root.getAttribute("data-theme")
+ if (dataTheme === "dark") return true
+ if (dataTheme === "light") return false
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+}
+
+export function resolveDark(theme: ShaderSkyTheme): boolean {
+ if (theme === "dark") return true
+ if (theme === "light") return false
+ return isDarkTheme()
+}
+
+function compile(gl: WebGLRenderingContext, type: number, source: string) {
+ const shader = gl.createShader(type)
+ if (!shader) return null
+ gl.shaderSource(shader, source)
+ gl.compileShader(shader)
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ if (isDev()) {
+ console.warn(
+ "ShaderSky: shader failed to compile\n",
+ gl.getShaderInfoLog(shader)
+ )
+ }
+ gl.deleteShader(shader)
+ return null
+ }
+ return shader
+}
+
+/**
+ * WebGL sky for heroes — clear blue with drifting clouds in light,
+ * storm gray in dark. Optional window-glass film.
+ */
+export function createShaderSky(
+ canvas: HTMLCanvasElement,
+ initial: ShaderSkyOptions = {}
+): ShaderSkyInstance | null {
+ let options: Required<
+ Pick<
+ ShaderSkyOptions,
+ | "speed"
+ | "coverage"
+ | "intensity"
+ | "amount"
+ | "scale"
+ | "variation"
+ | "interactive"
+ | "glass"
+ | "glassSize"
+ | "theme"
+ >
+ > &
+ ShaderSkyOptions = {
+ speed: 0.1,
+ coverage: 0.5,
+ intensity: 0.9,
+ amount: 0.5,
+ scale: 0.4,
+ variation: 0.7,
+ interactive: false,
+ glass: false,
+ glassSize: 7,
+ theme: "auto",
+ ...initial,
+ }
+
+ const mouse = { x: 0.5, y: 0.5 }
+ const targetMouse = { x: 0.5, y: 0.5 }
+ let reduce = false
+ let dark = resolveDark(options.theme ?? "auto")
+ options.onThemeChange?.(dark)
+
+ const gl = canvas.getContext("webgl", {
+ alpha: false,
+ antialias: false,
+ depth: false,
+ stencil: false,
+ powerPreference: "high-performance",
+ })
+ if (!gl) return null
+
+ const vs = compile(gl, gl.VERTEX_SHADER, VERT)
+ const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)
+ if (!vs || !fs) {
+ if (vs) gl.deleteShader(vs)
+ if (fs) gl.deleteShader(fs)
+ return null
+ }
+
+ const program = gl.createProgram()
+ if (!program) {
+ gl.deleteShader(vs)
+ gl.deleteShader(fs)
+ return null
+ }
+ gl.attachShader(program, vs)
+ gl.attachShader(program, fs)
+ gl.linkProgram(program)
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ if (isDev()) {
+ console.warn(
+ "ShaderSky: program failed to link\n",
+ gl.getProgramInfoLog(program)
+ )
+ }
+ gl.deleteProgram(program)
+ gl.deleteShader(vs)
+ gl.deleteShader(fs)
+ return null
+ }
+ gl.useProgram(program)
+
+ const buf = gl.createBuffer()
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf)
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
+ gl.STATIC_DRAW
+ )
+ const loc = gl.getAttribLocation(program, "a_position")
+ gl.enableVertexAttribArray(loc)
+ gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
+
+ const uResolution = gl.getUniformLocation(program, "u_resolution")
+ const uTime = gl.getUniformLocation(program, "u_time")
+ const uSpeed = gl.getUniformLocation(program, "u_speed")
+ const uCoverage = gl.getUniformLocation(program, "u_coverage")
+ const uIntensity = gl.getUniformLocation(program, "u_intensity")
+ const uAmount = gl.getUniformLocation(program, "u_amount")
+ const uScale = gl.getUniformLocation(program, "u_scale")
+ const uVariation = gl.getUniformLocation(program, "u_variation")
+ const uGlass = gl.getUniformLocation(program, "u_glass")
+ const uGlassSize = gl.getUniformLocation(program, "u_glassSize")
+ const uC1 = gl.getUniformLocation(program, "u_c1")
+ const uC2 = gl.getUniformLocation(program, "u_c2")
+ const uC3 = gl.getUniformLocation(program, "u_c3")
+ const uC4 = gl.getUniformLocation(program, "u_c4")
+ const uMouse = gl.getUniformLocation(program, "u_mouse")
+
+ let raf = 0
+ let running = true
+ const start = performance.now()
+ let frozenTime = 0
+
+ const resize = () => {
+ const parent = canvas.parentElement
+ if (!parent) return
+ const dpr = Math.min(window.devicePixelRatio || 1, 2)
+ const w = parent.clientWidth
+ const h = parent.clientHeight
+ if (w <= 0 || h <= 0) return
+ canvas.width = Math.max(1, Math.floor(w * dpr))
+ canvas.height = Math.max(1, Math.floor(h * dpr))
+ canvas.style.width = `${w}px`
+ canvas.style.height = `${h}px`
+ gl.viewport(0, 0, canvas.width, canvas.height)
+ }
+
+ resize()
+ const ro = new ResizeObserver(resize)
+ if (canvas.parentElement) ro.observe(canvas.parentElement)
+
+ const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)")
+ const onReduce = () => {
+ reduce = mqReduce.matches
+ }
+ onReduce()
+ mqReduce.addEventListener("change", onReduce)
+
+ const syncTheme = () => {
+ const next = resolveDark(options.theme ?? "auto")
+ if (next === dark) return
+ dark = next
+ options.onThemeChange?.(dark)
+ }
+ const mo = new MutationObserver(syncTheme)
+ mo.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class", "data-theme", "style"],
+ })
+ const mqDark = window.matchMedia("(prefers-color-scheme: dark)")
+ mqDark.addEventListener("change", syncTheme)
+
+ const onMove = (e: PointerEvent) => {
+ if (!options.interactive) return
+ const parent = canvas.parentElement
+ if (!parent) return
+ const rect = parent.getBoundingClientRect()
+ if (rect.width <= 0 || rect.height <= 0) return
+ targetMouse.x = (e.clientX - rect.left) / rect.width
+ targetMouse.y = 1 - (e.clientY - rect.top) / rect.height
+ }
+ window.addEventListener("pointermove", onMove, { passive: true })
+
+ const tick = (now: number) => {
+ if (!running) return
+
+ if (reduce) {
+ if (frozenTime === 0) frozenTime = (now - start) / 1000
+ } else {
+ frozenTime = 0
+ }
+
+ const time = reduce ? frozenTime : (now - start) / 1000
+ const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS)
+ const palette = [0, 1, 2, 3].map((i) =>
+ hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!)
+ )
+
+ mouse.x += (targetMouse.x - mouse.x) * 0.03
+ mouse.y += (targetMouse.y - mouse.y) * 0.03
+
+ const dpr = Math.min(window.devicePixelRatio || 1, 2)
+ const glassPx = Math.max(2, options.glassSize ?? 7) * dpr
+
+ gl.uniform2f(uResolution, canvas.width, canvas.height)
+ gl.uniform1f(uTime, time)
+ gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.1))
+ gl.uniform1f(uCoverage, Math.min(1, Math.max(0, options.coverage ?? 0.5)))
+ gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.9)))
+ gl.uniform1f(uAmount, Math.min(1, Math.max(0, options.amount ?? 0.5)))
+ gl.uniform1f(uScale, Math.min(1, Math.max(0, options.scale ?? 0.4)))
+ gl.uniform1f(uVariation, Math.min(1, Math.max(0, options.variation ?? 0.7)))
+ gl.uniform1f(uGlass, options.glass ? 1 : 0)
+ gl.uniform1f(uGlassSize, glassPx)
+ gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2])
+ gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2])
+ gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2])
+ gl.uniform3f(uC4, palette[3]![0], palette[3]![1], palette[3]![2])
+ gl.uniform2f(
+ uMouse,
+ options.interactive ? mouse.x : 0.5,
+ options.interactive ? mouse.y : 0.5
+ )
+
+ gl.drawArrays(gl.TRIANGLES, 0, 6)
+ raf = requestAnimationFrame(tick)
+ }
+
+ raf = requestAnimationFrame(tick)
+
+ return {
+ setOptions(next) {
+ options = { ...options, ...next }
+ syncTheme()
+ },
+ destroy() {
+ running = false
+ cancelAnimationFrame(raf)
+ ro.disconnect()
+ mo.disconnect()
+ mqReduce.removeEventListener("change", onReduce)
+ mqDark.removeEventListener("change", syncTheme)
+ window.removeEventListener("pointermove", onMove)
+ gl.deleteProgram(program)
+ gl.deleteShader(vs)
+ gl.deleteShader(fs)
+ gl.deleteBuffer(buf)
+ },
+ }
+}
diff --git a/registry/shader-sky/shader-sky.svelte b/registry/shader-sky/shader-sky.svelte
new file mode 100644
index 0000000..aae0acd
--- /dev/null
+++ b/registry/shader-sky/shader-sky.svelte
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
diff --git a/registry/shader-sky/shader-sky.tsx b/registry/shader-sky/shader-sky.tsx
new file mode 100644
index 0000000..835a861
--- /dev/null
+++ b/registry/shader-sky/shader-sky.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+
+import { cn } from "@/lib/utils"
+
+import {
+ createShaderSky,
+ skyFallback,
+ resolveDark,
+ type ShaderSkyInstance,
+ type ShaderSkyOptions,
+} from "./shader-sky-vanilla"
+
+export {
+ DARK_COLORS,
+ DARK_FALLBACK,
+ LIGHT_COLORS,
+ LIGHT_FALLBACK,
+} from "./shader-sky-vanilla"
+export type {
+ ShaderSkyInstance,
+ ShaderSkyOptions,
+ ShaderSkyTheme,
+} from "./shader-sky-vanilla"
+
+export type ShaderSkyProps = Omit & {
+ className?: string
+}
+
+/**
+ * WebGL sky for heroes — clear blue with drifting clouds in light,
+ * storm gray in dark. Optional window-glass film.
+ */
+export function ShaderSky({
+ className,
+ colors,
+ speed = 0.1,
+ coverage = 0.5,
+ intensity = 0.9,
+ amount = 0.5,
+ scale = 0.4,
+ variation = 0.7,
+ interactive = false,
+ glass = false,
+ glassSize = 7,
+ theme = "auto",
+}: ShaderSkyProps) {
+ const canvasRef = useRef(null)
+ const instanceRef = useRef(null)
+ const [isDark, setIsDark] = useState(false)
+
+ useEffect(() => {
+ const sync = () => setIsDark(resolveDark(theme))
+ sync()
+ const mo = new MutationObserver(sync)
+ mo.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class", "data-theme"],
+ })
+ const mq = window.matchMedia("(prefers-color-scheme: dark)")
+ mq.addEventListener("change", sync)
+ return () => {
+ mo.disconnect()
+ mq.removeEventListener("change", sync)
+ }
+ }, [theme])
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+ instanceRef.current = createShaderSky(canvas, {
+ colors,
+ speed,
+ coverage,
+ intensity,
+ amount,
+ scale,
+ variation,
+ interactive,
+ glass,
+ glassSize,
+ theme,
+ onThemeChange: setIsDark,
+ })
+ return () => {
+ instanceRef.current?.destroy()
+ instanceRef.current = null
+ }
+ // Engine reads live options via setOptions; mount once.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ useEffect(() => {
+ instanceRef.current?.setOptions({
+ colors,
+ speed,
+ coverage,
+ intensity,
+ amount,
+ scale,
+ variation,
+ interactive,
+ glass,
+ glassSize,
+ theme,
+ onThemeChange: setIsDark,
+ })
+ }, [
+ colors,
+ speed,
+ coverage,
+ intensity,
+ amount,
+ scale,
+ variation,
+ interactive,
+ glass,
+ glassSize,
+ theme,
+ ])
+
+ const fallback = skyFallback(colors, isDark)
+
+ return (
+
+
+
+ )
+}