diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 7111928..caf159d 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -24,42 +24,17 @@ jobs: persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: - node-version: 20 + node-version: 20.19.0 cache: npm - run: npm ci - run: npm run build + - run: npm run build:playground - name: Build static playground artifact run: | mkdir -p _site - cp -R playground dist _site/ + cp -R dist-playground/* _site/ cp grain-gradient-og.png _site/screenshot.png touch _site/.nojekyll - cat > _site/index.html <<'HTML' - - - - - - - grain-gradient playground - - - - - - - - - - - - - - - Open playground - - - HTML - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa with: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7e9d061..26c703f 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: - node-version: 20 + node-version: 20.19.0 cache: npm - run: npm ci diff --git a/.gitignore b/.gitignore index 0ca39c0..5e47a8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules dist +dist-playground .DS_Store +.slim/deepwork/ diff --git a/README.md b/README.md index bdc6a86..58503b3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # grain-gradient -Lightweight TypeScript helpers for mesh + grain gradients. +Lightweight TypeScript helpers for WebGL shader mesh + grain gradients. + +> **v2 is WebGL-only.** The library now renders through a single WebGL fragment shader. CSS/SVG gradient generation APIs from v1 (`createGrainGradientCSS`, `createMeshGradient`, `createTurbulenceNoise`, `createAndroidCanvasFallbackStyle`, and related Android/canvas fallback helpers) have been removed. ![grain-gradient playground preview](https://raw.githubusercontent.com/aomona/grain-gradient/main/grain-gradient-og.png) -[Open the playground](https://aomona.github.io/grain-gradient/playground/) +[Open the playground](https://aomona.github.io/grain-gradient/) ## Installation @@ -12,120 +14,79 @@ Lightweight TypeScript helpers for mesh + grain gradients. npm i grain-gradient ``` -## Core CSS +## WebGL shader renderer -```ts -import { createGrainGradientCSS, presets } from "grain-gradient"; - -const css = createGrainGradientCSS({ - ...presets["Aurora Citrus"], - motionPreset: "drift", - motionSpeed: 38, - motionIntensity: 46, - swirl: 30, -}); -``` - -`grain-gradient` has no runtime dependencies. The core entry does not import React. - -The core API, playground, and React helper can switch to a Canvas-generated PNG grain fallback for Android Chrome device testing. The fallback helpers are SSR-safe: SVG grain is rendered first, then Canvas grain can be applied after hydration when Android Chrome is detected. - -```ts -import { createAndroidCanvasFallbackStyle } from "grain-gradient"; - -const fallback = createAndroidCanvasFallbackStyle({ androidCanvasFallback: "auto" }); -if (fallback) Object.assign(grainLayer.style, fallback); -``` - -`auto` is resolved where the helper runs. If you are exporting static CSS on a non-Android browser and want the Canvas fallback included, use `androidCanvasFallback: "on"` for that export. - -For CSS generated with `createGrainGradientCSS()`, apply those values to the generated `::after` grain layer as a CSS override. - -See [API reference](./docs/API.md) for all core functions, React helpers, options, and presets. - -## React +`grain-gradient` renders both the mesh gradient and the grain texture in one WebGL fragment shader. The React component keeps only a minimal `baseColor` background behind the canvas, so SSR and unsupported WebGL environments do not render a blank transparent box. ```tsx -import { GrainGradient } from "grain-gradient/react"; +import { GrainGradient, presets } from "grain-gradient/react"; export function Hero() { return ( ); } ``` -For SSR frameworks, pass the request user agent as a hint so `auto` can use the same Android Chrome detection after hydration: +The component creates a `` context on the client. If WebGL is unavailable or the WebGL context is lost, the component keeps only the `baseColor` background; it does not provide a CSS/SVG or 2D canvas fallback. -```tsx -import { headers } from "next/headers"; -import { GrainGradient } from "grain-gradient/react"; +In SSR frameworks such as Next.js, render it from a client boundary (`"use client"`) or with a dynamic import using `ssr: false`. -export default async function Page() { - const userAgent = (await headers()).get("user-agent"); +```tsx +import dynamic from "next/dynamic"; - return ; -} +const GrainGradient = dynamic( + () => import("grain-gradient/react").then((mod) => mod.GrainGradient), + { ssr: false }, +); ``` -React is a peer dependency via the `grain-gradient/react` subpath. - -## WebGL experimental +## Framework-agnostic renderer -For continuously animated mesh backgrounds, use the optional WebGL renderer. The default CSS/SVG renderer remains the SSR-safe fallback. - -The WebGL React component is client-only because it creates a `` context. In SSR frameworks such as Next.js, render it from a client boundary (`"use client"`) or a dynamic import with `ssr: false`, and keep CSS/SVG available as the fallback for server output, static exports, unsupported browsers, and lost WebGL contexts. +```ts +import { createWebGLMeshRenderer } from "grain-gradient"; -```tsx -import { WebGLGrainGradient } from "grain-gradient/webgl/react"; +const canvas = document.querySelector("canvas")!; +const renderer = createWebGLMeshRenderer(canvas, { + colors: ["#c2e812", "#ff7f11", "#ee4266", "#2a1e5c"], + motionPreset: "drift", + motionSpeed: 38, + motionIntensity: 46, + opacity: 0.22, +}); -export function AnimatedHero() { - return ( - - ); -} +renderer?.start(); ``` -```tsx -import dynamic from "next/dynamic"; +Call `renderer.update(options)` when controls change, `renderer.resize()` when the canvas layout changes, and `renderer.destroy()` during cleanup. -const WebGLGrainGradient = dynamic( - () => import("grain-gradient/webgl/react").then((mod) => mod.WebGLGrainGradient), - { ssr: false }, -); -``` +## Performance defaults -Use CSS/SVG for static backgrounds and exports; use WebGL as the default choice when smooth continuous mesh animation matters. WebGL renders once and stops when motion is disabled. When motion is enabled, the defaults are tuned for full-screen performance: `fps: 30` and `motionMaxPixelRatio: 0.75`. Raise them only when you need smoother motion or sharper animated rendering. `maxPixelRatio` can stay a little higher for static sharpness. +Animated fullscreen rendering is tuned conservatively by default: -```tsx -// Explicit lightweight animated fullscreen preset, matching the defaults - -``` +- `fps: 30` +- `maxPixelRatio: 1.25` +- `motionMaxPixelRatio: 0.75` +- `pauseWhenHidden: true` + +Raise these only when you need sharper or smoother animated rendering. ## Development - `npm run build`: compile the TypeScript source to `dist/` +- `npm run build:playground`: build the Vite React playground to `dist-playground/` - `npm run test`: run the Node test suite after building - `npm run lint`: check the codebase with oxlint - `npm run format:check`: verify formatting with oxfmt -- `npm run playground`: open the Vite playground at `/playground/` +- `npm run playground`: open the Vite playground at `/` ## Local playground @@ -135,6 +96,4 @@ Run the Vite-powered playground with hot reload: npm run dev ``` -Then open `http://localhost:5173/playground/` to tune presets, colors, and grain settings live. - -Use `npm run playground` to start Vite and open the playground automatically. +Then open `http://localhost:5173/`. diff --git a/docs/API.md b/docs/API.md index 9b0e845..1b0cb60 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,21 +1,17 @@ # API reference -`grain-gradient` provides a dependency-free core API and optional React helpers via a subpath export. +`grain-gradient` provides a WebGL shader renderer and optional React helpers. Both the mesh gradient and the grain texture are generated in the fragment shader. + +> **v2 is WebGL-only.** CSS/SVG gradient generation APIs from v1 were removed, including `createGrainGradientCSS`, `createMeshGradient`, `createTurbulenceNoise`, `createAndroidCanvasFallbackStyle`, and related Android/canvas fallback helpers. The React component falls back to a plain `baseColor` background when WebGL is unavailable or the WebGL context is lost. ## Import paths ```ts import { - createAndroidCanvasFallbackStyle, - createCanvasGrainBackgroundSize, - createCanvasGrainNoise, - createGrainGradientCSS, - createGrainLayerStyle, - createMeshGradient, - createTurbulenceNoise, - isAndroidChrome, + createWebGLMeshRenderer, + isWebGLAvailable, presets, - shouldUseAndroidCanvasFallback, + resolveWebGLMeshGradientOptions, } from "grain-gradient"; ``` @@ -23,339 +19,112 @@ import { import { GrainGradient, useGrainGradient } from "grain-gradient/react"; ``` +Compatibility subpaths are still available: + ```ts import { createWebGLMeshRenderer } from "grain-gradient/webgl"; -``` - -```tsx import { WebGLGrainGradient } from "grain-gradient/webgl/react"; ``` -`grain-gradient/react` is an import path, not a separate package. - -## Core API - -### `createTurbulenceNoise(options?)` - -Creates a CSS `url("data:image/svg+xml,...")` value using SVG `feTurbulence`. - -```ts -const noise = createTurbulenceNoise({ - frequency: 1.25, - contrast: 1.7, - seed: 8, -}); -``` - -Options: - -| Option | Default | Range / note | -| --------------- | ------: | ---------------------------------- | -| `frequency` | `1.25` | Clamped to `0.04` – `2.4` | -| `baseFrequency` | — | Alias fallback for `frequency` | -| `contrast` | `1.7` | Clamped to `1.0` – `2.5` | -| `seed` | `1` | Clamped to `0` – `9999` | -| `numOctaves` | `2` | Clamped to `1` – `5` | -| `stitchTiles` | `true` | Uses SVG `stitchTiles` | -| `width` | `3200` | SVG canvas width | -| `height` | `2200` | SVG canvas height | -| `size` | — | Fallback for both width and height | - -The SVG helper always returns SVG turbulence noise. Use the Canvas fallback helpers below when Android Chrome needs a PNG grain fallback. - -### Canvas fallback helpers - -The core entry also exposes framework-agnostic helpers for applying the Android Chrome Canvas fallback outside React: - -```ts -import { createAndroidCanvasFallbackStyle } from "grain-gradient"; - -const fallback = createAndroidCanvasFallbackStyle({ - androidCanvasFallback: "auto", - seed: 8, - frequency: 1.25, - contrast: 1.7, -}); - -if (fallback) Object.assign(grainLayer.style, fallback); -``` - -`androidCanvasFallback: "auto"` is resolved where this helper runs. For runtime usage, call it in the browser after hydration. For static CSS export, use `"on"` when you want the PNG fallback CSS included regardless of the browser used to generate the CSS. - -If you use `createGrainGradientCSS()` and its `::after` grain pseudo-element, append an override instead of assigning element styles: - -```ts -const selector = ".grain-gradient"; -const fallback = createAndroidCanvasFallbackStyle({ androidCanvasFallback: "auto" }); - -const fallbackCss = fallback - ? `${selector}::after { - background-image: ${fallback.backgroundImage}; - background-size: ${fallback.backgroundSize}; - background-repeat: ${fallback.backgroundRepeat}; - image-rendering: ${fallback.imageRendering}; -}` - : ""; -``` - -- `isAndroidChrome(userAgent?)` detects Android Chrome while excluding WebView, Edge, Opera, Samsung Browser, and Firefox. -- `shouldUseAndroidCanvasFallback(fallback, userAgent?)` resolves `"auto" | "on" | "off"`. -- `createCanvasGrainNoise(options?)` returns a Canvas-generated PNG CSS `url(...)` in browsers, or `null` when `document` / Canvas is unavailable. -- `createCanvasGrainBackgroundSize(options?)` returns the repeated PNG tile size for the provided grain frequency. -- `createGrainLayerStyle(options?)` returns the SVG grain layer sizing (`background-size` + `background-repeat`). The SVG grain is drawn at a fixed CSS-pixel size, then repeated, so grain density is not tied to each container's dimensions. -- `createAndroidCanvasFallbackStyle(options?)` returns `{ backgroundImage, backgroundSize, backgroundRepeat, imageRendering }` when the fallback applies and Canvas generation succeeds; otherwise it returns `null`. +## `createWebGLMeshRenderer(canvas, options?)` -Like the React helper, these functions are SSR-safe as long as they are called after hydration or guarded by their `null` return value. - -### `createMeshGradient(options?)` - -Creates a CSS `background-image` value with radial gradients and a base linear gradient. +Creates a WebGL renderer for an existing ``. ```ts -const mesh = createMeshGradient({ +const renderer = createWebGLMeshRenderer(canvas, { colors: ["#c2e812", "#ff7f11", "#ee4266", "#2a1e5c"], baseColor: "#0f1020", -}); -``` - -This returns only the background image value, not full CSS declarations. - -Options: - -| Option | Default | Note | -| ------------ | ---------------------------------------------- | --------------------------------------------------------------------------------- | -| `colors` | `['#7c3aed', '#06b6d4', '#f97316', '#f43f5e']` | Uses up to 6 colors | -| `baseColor` | `#0b1020` | Used in the base linear gradient | -| `blur` | — | Used by CSS/React layer helpers, not the returned image string | -| `saturation` | — | Used by CSS/React layer helpers | -| `intensity` | — | Fallback alias for saturation in layer helpers | -| `swirl` | `0` | `0` – `100`; rotates, scales, and repositions the mesh layer in CSS/React helpers | - -### `createGrainGradientCSS(options?)` - -Creates framework-independent CSS with a root selector, a mesh `::before` layer, and a grain `::after` layer. - -```ts -const css = createGrainGradientCSS({ - selector: ".grain-gradient", - colors: ["#c2e812", "#ff7f11", "#ee4266", "#2a1e5c"], - opacity: 0.2, - blendMode: "overlay", motionPreset: "drift", motionSpeed: 38, motionIntensity: 46, swirl: 30, + opacity: 0.22, }); -``` -Options include all `createMeshGradient` and `createTurbulenceNoise` options, plus: +renderer?.start(); +``` -| Option | Default | Note | -| ----------------- | ----------------- | ------------------------------------------ | -| `selector` | `.grain-gradient` | CSS selector for the generated snippet | -| `opacity` | `0.2` | Grain layer opacity | -| `blendMode` | `overlay` | Grain layer `mix-blend-mode` | -| `motionPreset` | `none` | `none`, `drift`, `breathe`, or `orbit` | -| `motionSpeed` | `0` | `0` – `100`; `0` disables animation | -| `motionIntensity` | `50` | `0` – `100`; controls travel/zoom strength | +Returns `null` if WebGL is unavailable or shader setup fails. -`swirl` is part of the mesh options and is clamped to `0` – `100`. It affects the generated `::before` mesh layer by adjusting `background-size`, `background-position`, and `transform`; `createMeshGradient()` itself still returns only a `background-image` value. +### Renderer methods -The generated grain layer uses: +- `start()`: start rendering. Static gradients render once; animated gradients render on `requestAnimationFrame`. +- `stop()`: stop the animation loop. +- `update(options)`: merge and apply new options. +- `resize()`: sync the canvas backing size and viewport with the rendered element size. +- `destroy()`: stop rendering and release WebGL resources. -```css -background-size: 3200px 2200px; -background-repeat: repeat; -pointer-events: none; -contain: paint; -``` +## `resolveWebGLMeshGradientOptions(options?)` -The fixed CSS-pixel grain size keeps visible roughness more consistent across different container sizes and display scale factors, and avoids stretching the SVG noise to every element. Pass `width` / `height` / `size` when you want a different fixed grain canvas; these options now affect both SVG dimensions and the visible grain tile size. +Normalizes renderer options and clamps performance-sensitive values. This function is safe to call without browser globals. -When motion is enabled, the generated CSS appends `animation` declarations, scoped `@keyframes`, and a `prefers-reduced-motion: reduce` override for the selected preset. The animation is CSS-only, so the SVG noise data URL is not regenerated per frame. -`will-change: transform` is only emitted while motion is enabled to avoid keeping extra compositor layers alive for static backgrounds. Paint containment is only applied to the grain layer, not the blurred mesh layer, to avoid clipping blur overflow. +## `isWebGLAvailable()` -The CSS motion presets are transform-only. `breathe` no longer animates `filter`, and `orbit` no longer animates `background-position`, because both properties can trigger repeated paint work on large blurred backgrounds. +Returns `false` on the server and otherwise checks whether a WebGL context can be created. -## React API +## React ### `` -Renders a background-only root with two absolute layers: mesh and grain. +Client-side WebGL component. When WebGL is unavailable or the WebGL context is lost, the component renders only the `baseColor` background; no CSS/SVG or 2D canvas fallback is provided. ```tsx -import { GrainGradient } from "grain-gradient/react"; - -export function Background() { - return ( - - ); -} +import { GrainGradient, presets } from "grain-gradient/react"; + +; ``` -Props: +Props include all renderer options plus: -- All `createGrainGradientCSS` options -- `androidCanvasFallback?: "auto" | "on" | "off"` — Built-in Canvas PNG fallback on Android Chrome, powered by the core fallback helpers. `auto` detects Android Chrome after hydration; SSR renders SVG grain first, so there is no server-side `window`, `navigator`, or `canvas` access. The Canvas fallback uses `seed`, `frequency` / `baseFrequency`, and `contrast`; SVG-specific sizing options remain SVG-only. -- `androidCanvasFallbackUserAgent?: string | null` — Optional SSR user-agent hint for `androidCanvasFallback="auto"`. Pass the request UA from frameworks such as Next.js so the post-hydration fallback decision matches server-known user-agent data. - `className?: string` - `style?: React.CSSProperties` +- `canvasStyle?: React.CSSProperties` - `children?: React.ReactNode` -The component does not add text or UI by itself. If children are passed, they are rendered above the background layers. -When layering children, keep decorative layers `aria-hidden` and raise interactive/content children with `position`/`z-index` as needed so they remain accessible and clickable. - -SSR framework example with Next.js App Router: - -```tsx -import { headers } from "next/headers"; -import { GrainGradient } from "grain-gradient/react"; - -export default async function Page() { - const userAgent = (await headers()).get("user-agent"); - - return ; -} -``` - -The user-agent hint is only used after hydration to decide whether to generate Canvas grain. The server render remains SVG-only to avoid hydration mismatches and server-side Canvas requirements. - ### `useGrainGradient(options?)` -Returns computed styles for custom composition. - -```tsx -const { rootStyle, meshStyle, grainStyle, cssText } = useGrainGradient({ - colors: presets["Blue Hour"].colors, -}); -``` - -Return value: - -| Key | Description | -| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `rootStyle` | Relative/overflow-hidden container style | -| `meshStyle` | Mesh layer style | -| `grainStyle` | Active grain layer style. It starts as fixed-size repeated SVG turbulence and may switch to repeated Canvas PNG after hydration when `androidCanvasFallback` applies. | -| `cssText` | Minimal mesh background CSS text | - -## WebGL API experimental - -Animated fullscreen mesh gradients can be rendered with an optional WebGL canvas renderer. The default CSS/SVG renderer remains the SSR-safe baseline and fallback; WebGL is intended for client-side animated mesh motion. - -```ts -import { createWebGLMeshRenderer } from "grain-gradient/webgl"; - -const canvas = document.querySelector("canvas")!; -const renderer = createWebGLMeshRenderer(canvas, { - colors: ["#c2e812", "#ff7f11", "#ee4266", "#2a1e5c"], - motionPreset: "drift", - motionSpeed: 38, - motionIntensity: 46, - maxPixelRatio: 1.25, - motionMaxPixelRatio: 0.75, - fps: 30, -}); - -renderer?.start(); -``` - -The renderer exposes: +Returns minimal `rootStyle` and `canvasStyle` objects used by the component. It does not create a renderer. -- `update(options)` — update colors, saturation, swirl, motion, `maxPixelRatio`, `motionMaxPixelRatio`, or `fps` -- `start()` / `stop()` — control the animation loop -- `resize()` — sync the canvas backing store to its CSS size and capped DPR -- `destroy()` — stop animation and release WebGL resources +## Options -React users can use the progressive-enhancement component: +### Mesh options -```tsx -import { WebGLGrainGradient } from "grain-gradient/webgl/react"; +- `colors?: string[]` — up to six colors. Fewer colors are repeated to fill shader uniforms. +- `baseColor?: string` — fallback background color and shader base color. Default: `"#0b1020"`. +- `intensity?: number` — alias for default saturation when `saturation` is omitted. +- `saturation?: number` — clamped `0.2` to `2.5`. Default: `1.18`. +- `swirl?: number` — clamped `0` to `100`. -export function AnimatedBackground() { - return ( - - ); -} -``` - -`WebGLGrainGradient` renders the existing CSS mesh as fallback, then fades in a WebGL canvas after the client creates a working context. The SVG/canvas grain layer is still overlaid above the WebGL mesh. If WebGL is unavailable or the context is lost, the CSS fallback remains visible. +### Grain options -WebGL options: +- `opacity?: number` — shader grain amount, clamped `0` to `1`. Default: `0.2`. +- `frequency?: number` / `baseFrequency?: number` — grain density, clamped `0.04` to `2.4`. Default: `1.25`. +- `contrast?: number` — grain contrast, clamped `1` to `2.5`. Default: `1.7`. +- `seed?: number` — integer seed, clamped `0` to `9999`. Default: `1`. -| Option | Default | Note | -| --------------------- | -------------------------- | ------------------------------------------------------------------ | -| `maxPixelRatio` | `1.25` | Caps static canvas backing resolution to avoid high-DPR GPU spikes | -| `motionMaxPixelRatio` | `min(maxPixelRatio, 0.75)` | Caps canvas backing resolution while motion is active | -| `fps` | `30` | Caps renderer frame rate | -| `pauseWhenHidden` | `true` | Skips drawing while `document.hidden` | +### Motion options -Prefer CSS/SVG for static or SSR-only backgrounds. Prefer WebGL as the default renderer for smooth continuous mesh animation. +- `motionPreset?: "none" | "drift" | "breathe" | "orbit"`. Default: `"none"`. +- `motionSpeed?: number` — clamped `0` to `100`. Motion is disabled when `0`. +- `motionIntensity?: number` — clamped `0` to `100`. Default: `50`. -Performance notes: +### WebGL/performance options -- When `motionPreset` is `"none"` or `motionSpeed` is `0`, WebGL draws once and stops instead of keeping a `requestAnimationFrame` loop alive. -- While motion is active, blob position and size animation is computed once per frame and passed to the shader as uniforms, avoiding per-pixel trigonometry in the fragment shader. -- For sharper or smoother animated backgrounds, raise `motionMaxPixelRatio` toward `1` to `1.5` or `fps` toward `45` to `60`. - -```tsx - -``` +- `maxPixelRatio?: number` — static render pixel-ratio cap, clamped `0.5` to `3`. Default: `1.25`. +- `motionMaxPixelRatio?: number` — animated render pixel-ratio cap, clamped `0.5` to `3`. Default: `min(maxPixelRatio, 0.75)`. +- `fps?: number` — animation frame cap, clamped `1` to `60`. Default: `30`. +- `pauseWhenHidden?: boolean` — skips animated draws while `document.hidden`. Default: `true`. ## Presets -```ts -import { presets } from "grain-gradient"; -``` - -Available presets: - - `Aurora Citrus` - `Blue Hour` - `Candy Fog` - `Forest Glass` - `Midnight Bloom` - -Each preset contains: - -```ts -{ - colors: string[]; - baseColor: string; -} -``` - -Example: - -```tsx - -``` - -## Local playground - -```bash -npm run playground -``` - -The playground is a static, dependency-free preview app served by a tiny Node HTTP server. It imports the built core API from `dist/index.js`. diff --git a/package-lock.json b/package-lock.json index 548f17f..887f6bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,20 @@ { "name": "grain-gradient", - "version": "1.2.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "grain-gradient", - "version": "1.2.0", + "version": "2.0.0", "license": "MIT", "devDependencies": { - "@types/react": "^18.3.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", "oxfmt": "^0.51.0", "oxlint": "^1.66.0", + "react-dom": "^19.2.7", "typescript": "^5.5.4", "vite": "^8.0.14" }, @@ -1069,24 +1072,52 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/react": { - "version": "18.3.29", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", - "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1572,15 +1603,28 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -1615,6 +1659,13 @@ "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index c62e2a2..782cceb 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "grain-gradient", - "version": "1.2.0", - "description": "Lightweight TypeScript utilities for mesh and grain gradients.", + "version": "2.0.0", + "description": "Lightweight WebGL shader utilities for mesh and grain gradients.", "keywords": [ "background", "gradient", "grain", "react", - "svg" + "shader", + "webgl" ], "homepage": "https://github.com/aomona/grain-gradient#readme", "bugs": { @@ -46,19 +47,23 @@ "scripts": { "build": "tsc -p tsconfig.json", "dev": "vite", + "build:playground": "vite build", "lint": "oxlint .", "lint:fix": "oxlint . --fix", "format": "oxfmt --write .", "format:check": "oxfmt --check .", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p playground/tsconfig.json", "pretest": "npm run build", "test": "node --test tests/*.test.mjs", - "playground": "vite --open /playground/" + "playground": "vite --open" }, "devDependencies": { - "@types/react": "^18.3.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", "oxfmt": "^0.51.0", "oxlint": "^1.66.0", + "react-dom": "^19.2.7", "typescript": "^5.5.4", "vite": "^8.0.14" }, diff --git a/playground/index.html b/playground/index.html index c07ef70..53e0ea7 100644 --- a/playground/index.html +++ b/playground/index.html @@ -4,1108 +4,10 @@ grain-gradient playground - - - - - - - - - - - - - + -
- - -
- -
- -
- - +
+ diff --git a/playground/src/App.tsx b/playground/src/App.tsx new file mode 100644 index 0000000..df98f1d --- /dev/null +++ b/playground/src/App.tsx @@ -0,0 +1,445 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { MotionPreset } from "../../src/core.js"; +import { presets } from "../../src/core.js"; +import { + createWebGLMeshRenderer, + type WebGLMeshGradientOptions, + type WebGLMeshRenderer, +} from "../../src/webgl.js"; + +type PresetName = keyof typeof presets; + +interface PlaygroundState { + preset: PresetName; + colors: string[]; + baseColor: string; + intensity: number; + saturation: number; + swirl: number; + opacity: number; + frequency: number; + contrast: number; + seed: number; + motionPreset: MotionPreset; + motionSpeed: number; + motionIntensity: number; +} + +interface FeedbackState { + state: "ready" | "error"; + title: string; + text: string; +} + +const presetNames = Object.keys(presets) as PresetName[]; +const initialPreset: PresetName = "Aurora Citrus"; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const format = (value: number, digits = 2) => { + const next = Number(value).toFixed(digits); + return next.replace(/\.00$/, "").replace(/(\.\d)0$/, "$1"); +}; + +const defaultMotionPreset = (): MotionPreset => + typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "none" + : "drift"; + +const createInitialState = (): PlaygroundState => ({ + preset: initialPreset, + colors: [...presets[initialPreset].colors], + baseColor: presets[initialPreset].baseColor, + intensity: 1.45, + saturation: 1.28, + swirl: 30, + opacity: 0.2, + frequency: 1.25, + contrast: 1.7, + seed: 1, + motionPreset: defaultMotionPreset(), + motionSpeed: 38, + motionIntensity: 46, +}); + +export function App() { + const [state, setState] = useState(createInitialState); + const [feedback, setFeedback] = useState({ + state: "ready", + title: "Ready", + text: "Preview is live.", + }); + const [ready, setReady] = useState(false); + + const canvasRef = useRef(null); + const rendererRef = useRef(null); + const rendererOptionsRef = useRef({}); + + const set = (key: Key, value: PlaygroundState[Key]) => + setState((previous) => ({ ...previous, [key]: value })); + + const rendererOptions = useMemo( + () => ({ + colors: state.colors, + baseColor: state.baseColor, + intensity: state.intensity, + saturation: state.saturation, + swirl: state.swirl, + seed: state.seed, + frequency: state.frequency, + contrast: state.contrast, + opacity: state.opacity, + motionPreset: state.motionPreset, + motionSpeed: state.motionSpeed, + motionIntensity: state.motionIntensity, + maxPixelRatio: 1.25, + fps: 45, + }), + [state], + ); + rendererOptionsRef.current = rendererOptions; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + let activeRenderer: WebGLMeshRenderer | null = null; + let resizeObserver: ResizeObserver | null = null; + + const stopAndDestroy = () => { + activeRenderer?.destroy(); + activeRenderer = null; + rendererRef.current = null; + }; + + const handleResize = () => activeRenderer?.resize(); + + const createRenderer = () => { + stopAndDestroy(); + const renderer = createWebGLMeshRenderer(canvas, rendererOptionsRef.current); + if (!renderer) { + setReady(false); + setFeedback({ + state: "error", + title: "WebGL unavailable", + text: "This browser could not create a WebGL context.", + }); + return; + } + activeRenderer = renderer; + rendererRef.current = renderer; + renderer.resize(); + renderer.start(); + setReady(true); + setFeedback({ state: "ready", title: "Ready", text: "Preview is live." }); + }; + + const handleContextLost = (event: Event) => { + event.preventDefault(); + stopAndDestroy(); + setReady(false); + setFeedback({ + state: "error", + title: "WebGL context lost", + text: "The WebGL context was lost. Restoring if the browser allows it...", + }); + }; + + const handleContextRestored = (event: Event) => { + event.preventDefault(); + createRenderer(); + }; + + resizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(handleResize); + resizeObserver?.observe(canvas); + if (!resizeObserver) window.addEventListener("resize", handleResize); + canvas.addEventListener("webglcontextlost", handleContextLost); + canvas.addEventListener("webglcontextrestored", handleContextRestored); + + createRenderer(); + + return () => { + canvas.removeEventListener("webglcontextlost", handleContextLost); + canvas.removeEventListener("webglcontextrestored", handleContextRestored); + resizeObserver?.disconnect(); + if (!resizeObserver) window.removeEventListener("resize", handleResize); + stopAndDestroy(); + setReady(false); + }; + }, []); + + useEffect(() => { + rendererRef.current?.update(rendererOptions); + }, [rendererOptions]); + + const applyPreset = (name: PresetName) => + setState((previous) => ({ + ...previous, + preset: name, + colors: [...presets[name].colors], + baseColor: presets[name].baseColor, + })); + + const syncSeed = (value: string) => set("seed", clamp(Math.round(Number(value) || 0), 0, 9999)); + + const resetPreset = () => { + const next = createInitialState(); + next.colors = [...presets[initialPreset].colors]; + next.baseColor = presets[initialPreset].baseColor; + setState(next); + }; + + const motionSpeedLabel = + state.motionPreset === "none" || state.motionSpeed === 0 ? "static" : `${state.motionSpeed}`; + + return ( +
+ + +
+
+
+ ); +} diff --git a/playground/src/main.tsx b/playground/src/main.tsx new file mode 100644 index 0000000..185a4d5 --- /dev/null +++ b/playground/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; +import "./styles.css"; + +const container = document.getElementById("root"); +if (!container) throw new Error("Missing #root element"); + +createRoot(container).render( + + + , +); diff --git a/playground/src/styles.css b/playground/src/styles.css new file mode 100644 index 0000000..9cf2774 --- /dev/null +++ b/playground/src/styles.css @@ -0,0 +1,293 @@ +:root { + color-scheme: dark; + --bg: #070708; + --sidebar: #0f0f10; + --panel: #151518; + --panel-2: #111113; + --line: #27272a; + --line-strong: #3f3f46; + --text: #f4f4f5; + --muted: #a1a1aa; + --accent: #e4e4e7; + --accent-soft: rgba(228, 228, 231, 0.12); + --danger: #fca5a5; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +.app { + height: 100vh; + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + overflow: hidden; +} + +.sidebar { + background: var(--sidebar); + border-right: 1px solid var(--line); + padding: 18px 16px; + overflow: auto; +} + +.brand { + margin: 0 0 18px; +} + +.brand h1 { + margin: 0 0 6px; + font-size: 1rem; + line-height: 1.2; + font-weight: 600; + letter-spacing: -0.02em; +} + +.brand p { + margin: 0; + color: var(--muted); + font-size: 0.82rem; + line-height: 1.45; +} + +.controls { + display: grid; + gap: 12px; +} + +.card { + border: 1px solid var(--line); + background: var(--panel); + border-radius: 14px; + padding: 12px; +} + +.field { + display: grid; + gap: 8px; +} + +.field + .field { + margin-top: 10px; +} + +.label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +label { + font-size: 0.78rem; + color: var(--text); + letter-spacing: 0.02em; +} + +.value { + color: var(--muted); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +select, +input, +button { + font: inherit; +} + +select, +input[type="number"] { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel-2); + color: var(--text); + padding: 10px 12px; + outline: none; +} + +select:focus-visible, +input:focus-visible, +button:focus-visible { + border-color: var(--line-strong); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06); +} + +input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +input[type="color"] { + width: 100%; + height: 36px; + padding: 0; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel-2); +} + +.colors { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.color-field { + display: grid; + gap: 6px; +} + +.seed-grid { + display: grid; + gap: 8px; + grid-template-columns: 1fr 92px; +} + +.motion-help { + margin: -2px 0 0; + color: var(--muted); + font-size: 0.74rem; + line-height: 1.4; +} + +.button-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +button { + border: 1px solid var(--line); + background: #e5e7eb; + color: #09090b; + border-radius: 10px; + padding: 10px 12px; + cursor: pointer; + transition: + background 160ms ease, + border-color 160ms ease, + transform 160ms ease; +} + +button:hover { + transform: translateY(-1px); + background: #ffffff; +} + +button.secondary { + background: transparent; + color: var(--text); +} + +button.secondary:hover { + background: var(--accent-soft); +} + +button:disabled, +input:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.feedback { + border: 1px solid var(--line); + background: #101012; + border-radius: 12px; + padding: 10px 12px; + display: grid; + gap: 6px; +} + +.feedback strong { + font-size: 0.78rem; + font-weight: 600; +} + +.feedback p, +.feedback pre { + margin: 0; + font-size: 0.78rem; + line-height: 1.45; + color: var(--muted); + white-space: pre-wrap; + word-break: break-word; +} + +.feedback[data-state="error"] { + border-color: rgba(252, 165, 165, 0.35); +} + +.feedback[data-state="error"] p, +.feedback[data-state="error"] pre { + color: var(--danger); +} + +.preview { + position: relative; + min-height: 100vh; + overflow: hidden; + background: #050505; +} + +.preview-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + opacity: 0; + transition: opacity 180ms ease; + z-index: 0; +} + +.preview-canvas[data-ready="true"] { + opacity: 1; +} + +@media (max-width: 940px) { + .app { + height: auto; + min-height: 100vh; + grid-template-columns: 1fr; + overflow: visible; + } + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + overflow: visible; + } + .preview { + min-height: 68vh; + } +} + +@media (max-width: 560px) { + .sidebar { + padding: 14px; + } + .colors, + .seed-grid { + grid-template-columns: 1fr; + } +} diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 0000000..2cf6574 --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "../src/**/*.ts", "../src/**/*.tsx"] +} diff --git a/src/core.ts b/src/core.ts index f85757c..9f191f2 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,5 +1,3 @@ -import { clamp, createSwirlTransform, normalizeMotion, normalizeSwirl } from "./internal.js"; - export type GrainGradientPresetName = | "Aurora Citrus" | "Blue Hour" @@ -7,43 +5,10 @@ export type GrainGradientPresetName = | "Forest Glass" | "Midnight Bloom"; -export interface TurbulenceNoiseOptions { - seed?: number; - frequency?: number; - baseFrequency?: number; - numOctaves?: number; - stitchTiles?: boolean; - contrast?: number; - opacity?: number; - width?: number; - height?: number; - size?: number; -} - -export type AndroidCanvasFallback = "auto" | "on" | "off"; - -export interface AndroidCanvasFallbackOptions extends TurbulenceNoiseOptions { - androidCanvasFallback?: AndroidCanvasFallback; - androidCanvasFallbackUserAgent?: string | null; -} - -export interface CanvasGrainStyle { - backgroundImage: string; - backgroundSize: string; - backgroundRepeat: "repeat"; - imageRendering: "pixelated"; -} - -export interface GrainLayerStyle { - backgroundSize: string; - backgroundRepeat: "repeat"; -} - export interface MeshGradientOptions { colors?: string[]; baseColor?: string; intensity?: number; - blur?: number; saturation?: number; swirl?: number; } @@ -56,259 +21,15 @@ export interface MotionOptions { motionIntensity?: number; } -export interface GrainGradientCSSOptions - extends MeshGradientOptions, TurbulenceNoiseOptions, MotionOptions { - selector?: string; - blendMode?: string; -} - -const encodeSvg = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; - -const canvasNoiseCacheLimit = 24; -const canvasNoiseCache = new Map(); - -const getBrowserUserAgent = () => (typeof navigator === "undefined" ? "" : navigator.userAgent); - -const canvasNoiseKey = (options: TurbulenceNoiseOptions = {}) => { - const seed = Math.floor(clamp(options.seed ?? 1, 0, 9999)); - const frequency = clamp(options.frequency ?? options.baseFrequency ?? 1.25, 0.04, 2.4); - const density = (frequency - 0.04) / (2.4 - 0.04); - const blockSize = Math.max(1, Math.round(2.25 - density * 1.25)); - const contrast = clamp((options.contrast ?? 1.7) / 2.5, 0.35, 1).toFixed(3); - return JSON.stringify([seed, blockSize, contrast]); -}; - -const cacheCanvasNoise = (key: string, url: string) => { - if (canvasNoiseCache.has(key)) canvasNoiseCache.delete(key); - canvasNoiseCache.set(key, url); - while (canvasNoiseCache.size > canvasNoiseCacheLimit) { - const oldestKey = canvasNoiseCache.keys().next().value; - if (oldestKey === undefined) break; - canvasNoiseCache.delete(oldestKey); - } -}; - -export const isAndroidChrome = (userAgent = getBrowserUserAgent()) => { - const ua = userAgent; - return ( - /Android/i.test(ua) && - /Chrome\//i.test(ua) && - !/(; wv|Version\/|EdgA|OPR|SamsungBrowser|Firefox|CriOS)/i.test(ua) - ); -}; - -export const shouldUseAndroidCanvasFallback = ( - fallback: AndroidCanvasFallback | undefined, - userAgent?: string | null, -) => { - if (fallback === "on") return true; - if (fallback === "off") return false; - return isAndroidChrome(userAgent ?? getBrowserUserAgent()); -}; - -export function createCanvasGrainNoise(options: TurbulenceNoiseOptions = {}): string | null { - if (typeof document === "undefined") return null; - - const cacheKey = canvasNoiseKey(options); - const cached = canvasNoiseCache.get(cacheKey); - if (cached) { - cacheCanvasNoise(cacheKey, cached); - return cached; - } - - try { - const size = 1024; - const canvas = document.createElement("canvas"); - if (typeof canvas.getContext !== "function" || typeof canvas.toDataURL !== "function") { - return null; - } - canvas.width = size; - canvas.height = size; - - const context = canvas.getContext("2d", { alpha: false }); - if (!context || typeof context.createImageData !== "function") return null; - - const image = context.createImageData(size, size); - let value = Math.floor(clamp(options.seed ?? 1, 0, 9999)) >>> 0; - const nextRandom = () => { - value = (value * 1664525 + 1013904223) >>> 0; - return value / 4294967296; - }; - const frequency = clamp(options.frequency ?? options.baseFrequency ?? 1.25, 0.04, 2.4); - const density = (frequency - 0.04) / (2.4 - 0.04); - const contrast = clamp((options.contrast ?? 1.7) / 2.5, 0.35, 1); - const blockSize = Math.max(1, Math.round(2.25 - density * 1.25)); - - for (let y = 0; y < size; y += blockSize) { - for (let x = 0; x < size; x += blockSize) { - const bit = nextRandom() > 0.5 ? 1 : 0; - const channel = Math.round(128 + (bit ? 127 : -127) * contrast); - for (let yy = 0; yy < blockSize; yy++) { - for (let xx = 0; xx < blockSize; xx++) { - const px = x + xx; - const py = y + yy; - if (px >= size || py >= size) continue; - const offset = (py * size + px) * 4; - image.data[offset] = channel; - image.data[offset + 1] = channel; - image.data[offset + 2] = channel; - image.data[offset + 3] = 255; - } - } - } - } - - context.putImageData(image, 0, 0); - const url = `url("${canvas.toDataURL("image/png")}")`; - cacheCanvasNoise(cacheKey, url); - return url; - } catch { - return null; - } -} - -export function createCanvasGrainBackgroundSize(options: TurbulenceNoiseOptions = {}) { - const frequency = clamp(options.frequency ?? options.baseFrequency ?? 1.25, 0.04, 2.4); - const density = (frequency - 0.04) / (2.4 - 0.04); - return `${Math.round(720 - density * 580)}px ${Math.round(720 - density * 580)}px`; -} - -export function createGrainLayerStyle(options: TurbulenceNoiseOptions = {}): GrainLayerStyle { - const width = Math.floor(clamp(options.width ?? options.size ?? 3200, 256, 8192)); - const height = Math.floor(clamp(options.height ?? options.size ?? 2200, 256, 8192)); - - return { - backgroundSize: `${width}px ${height}px`, - backgroundRepeat: "repeat", - }; -} - -export function createAndroidCanvasFallbackStyle( - options: AndroidCanvasFallbackOptions = {}, -): CanvasGrainStyle | null { - if ( - !shouldUseAndroidCanvasFallback( - options.androidCanvasFallback, - options.androidCanvasFallbackUserAgent, - ) - ) { - return null; - } - - const backgroundImage = createCanvasGrainNoise(options); - if (!backgroundImage) return null; - - return { - backgroundImage, - backgroundSize: createCanvasGrainBackgroundSize(options), - backgroundRepeat: "repeat", - imageRendering: "pixelated", - }; -} - -const keyframeName = (selector: string) => - `grain-gradient-${selector.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "motion"}`; - -function createGrainGradientMotionCSS(options: GrainGradientCSSOptions = {}): string { - const selector = options.selector ?? ".grain-gradient"; - const motion = normalizeMotion(options); - const swirl = normalizeSwirl(options.swirl); - const swirlTransform = createSwirlTransform(swirl); - if (!motion.enabled) return ""; - - const name = keyframeName(selector); - const meshName = `${name}-mesh-${motion.preset}`; - const meshAnimation = `${meshName} ${motion.duration}s ease-in-out infinite alternate`; - - const motionKeyframes: Record, string> = { - drift: `@keyframes ${meshName} {\n 0% { transform: scale(1.12)${swirlTransform} translate3d(-${motion.travel}%, -${motion.travel}%, 0); }\n 100% { transform: scale(${motion.zoom})${swirlTransform} translate3d(${motion.travel}%, ${motion.travel}%, 0); }\n}`, - breathe: `@keyframes ${meshName} {\n 0% { transform: scale(1.12)${swirlTransform}; }\n 100% { transform: scale(${motion.zoom})${swirlTransform}; }\n}`, - orbit: `@keyframes ${meshName} {\n 0% { transform: scale(1.12)${swirlTransform} rotate(-${motion.rotate}deg) translate3d(-${motion.travel}%, ${motion.travel}%, 0); }\n 100% { transform: scale(${motion.zoom})${swirlTransform} rotate(${motion.rotate}deg) translate3d(${motion.travel}%, -${motion.travel}%, 0); }\n}`, - }; - const keyframes = motionKeyframes[motion.preset as Exclude]; - - return `${selector}::before { animation: ${meshAnimation}; }\n\n${keyframes}\n\n@media (prefers-reduced-motion: reduce) {\n ${selector}::before { animation: none; }\n}`; -} - -export function createTurbulenceNoise(options: TurbulenceNoiseOptions = {}): string { - const seed = Math.floor(clamp(options.seed ?? 1, 0, 9999)); - const baseFrequency = clamp(options.frequency ?? options.baseFrequency ?? 1.25, 0.04, 2.4); - const numOctaves = Math.floor(clamp(options.numOctaves ?? 2, 1, 5)); - const stitchTiles = options.stitchTiles ?? true; - const contrast = clamp(options.contrast ?? 1.7, 1.0, 2.5); - const width = Math.floor(clamp(options.width ?? options.size ?? 3200, 256, 8192)); - const height = Math.floor(clamp(options.height ?? options.size ?? 2200, 256, 8192)); - const offset = ((1 - contrast) / 2).toFixed(3); - - const svg = ``; - - return encodeSvg(svg); -} - -export function createMeshGradient(options: MeshGradientOptions = {}): string { - const colors = options.colors?.length - ? options.colors - : ["#7c3aed", "#06b6d4", "#f97316", "#f43f5e"]; - const baseColor = options.baseColor ?? "#0b1020"; - const stops = colors.slice(0, 6); - const positions = ["12% 18%", "86% 16%", "70% 82%", "20% 88%", "50% 46%", "18% 56%"]; - const sizes = [34, 32, 36, 32, 30, 28]; - const layers = stops.map((color, index) => { - const pos = positions[index] ?? positions[positions.length - 1]; - const size = sizes[index] ?? sizes[sizes.length - 1]; - return `radial-gradient(circle at ${pos}, ${color} 0, transparent ${size}%)`; - }); - return `${layers.join(", ")}, linear-gradient(135deg, ${stops.join(", ")}, ${baseColor})`; -} - -export function createGrainGradientCSS(options: GrainGradientCSSOptions = {}): string { - const selector = options.selector ?? ".grain-gradient"; - const motionCSS = createGrainGradientMotionCSS(options); - const motion = normalizeMotion(options); - const grainLayerStyle = createGrainLayerStyle(options); - const swirl = normalizeSwirl(options.swirl); - const swirlBackgroundPosition = swirl.enabled - ? ` background-position: ${swirl.backgroundPositionX}% ${swirl.backgroundPositionY}%;\n` - : ""; - const swirlTransform = createSwirlTransform(swirl); - return ` -${selector} { - position: relative; - overflow: hidden; - background-color: ${options.baseColor ?? "#0b1020"}; -} - -${selector}::before { - content: ""; - position: absolute; - inset: -18%; - background-image: ${createMeshGradient(options)}; - background-size: ${swirl.enabled ? `${swirl.backgroundSizeX}% ${swirl.backgroundSizeY}%` : "100% 100%"}; -${swirlBackgroundPosition} background-repeat: no-repeat; - filter: blur(${clamp(options.blur ?? 42, 0, 80)}px) saturate(${clamp(options.saturation ?? options.intensity ?? 1.18, 0.2, 2.5)}); - transform: scale(1.12)${swirlTransform}; - transform-origin: 50% 50%; - backface-visibility: hidden; -${motion.enabled ? " will-change: transform;\n" : ""} - z-index: 0; -} - -${selector}::after { - content: ""; - position: absolute; - inset: -8%; - pointer-events: none; - background-image: ${createTurbulenceNoise(options)}; - background-size: ${grainLayerStyle.backgroundSize}; - background-repeat: ${grainLayerStyle.backgroundRepeat}; - opacity: ${clamp(options.opacity ?? 0.2, 0, 1)}; - mix-blend-mode: ${options.blendMode ?? "overlay"}; - contain: paint; - z-index: 1; +export interface GrainOptions { + seed?: number; + frequency?: number; + baseFrequency?: number; + contrast?: number; + opacity?: number; } -${motionCSS}`.trim(); -} +export interface GrainGradientOptions extends MeshGradientOptions, MotionOptions, GrainOptions {} export const presets = { "Aurora Citrus": { colors: ["#c2e812", "#ff7f11", "#ee4266", "#2a1e5c"], baseColor: "#0f1020" }, diff --git a/src/index.ts b/src/index.ts index 5589f33..2100e0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from "./core.js"; +export * from "./webgl.js"; diff --git a/src/internal.ts b/src/internal.ts index fcf9a8a..cb99c35 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -1,28 +1,13 @@ +import type { MotionPreset } from "./core.js"; + export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); -export type MotionPreset = "none" | "drift" | "breathe" | "orbit"; - export const normalizeSwirl = (swirl = 0) => { const value = clamp(swirl, 0, 100); - const shift = value / 100; - return { - value, - enabled: value > 0, - scale: (1 + value * 0.004).toFixed(3), - rotate: (value * 0.12).toFixed(2), - offsetX: shift * 12, - offsetY: shift * -10, - backgroundSizeX: (100 + value * 0.55).toFixed(1), - backgroundSizeY: (100 + value * 0.4).toFixed(1), - backgroundPositionX: (50 + shift * 12).toFixed(1), - backgroundPositionY: (50 - shift * 10).toFixed(1), - }; + return { value, enabled: value > 0 }; }; -export const createSwirlTransform = (swirl: ReturnType) => - swirl.enabled ? ` scale(${swirl.scale}) rotate(${swirl.rotate}deg)` : ""; - export const motionPresets = new Set(["none", "drift", "breathe", "orbit"]); export const normalizeMotion = ( @@ -42,7 +27,6 @@ export const normalizeMotion = ( const travel = (4 + intensity * 0.16).toFixed(1); const zoom = (1.12 + intensity * 0.0018).toFixed(3); const rotate = (intensity * 0.12).toFixed(1); - const grainShift = (2 + intensity * 0.08).toFixed(1); return { preset, @@ -51,6 +35,5 @@ export const normalizeMotion = ( travel, zoom, rotate, - grainShift, }; }; diff --git a/src/react.tsx b/src/react.tsx index ce21048..e175f0c 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -1,165 +1,161 @@ -import { memo, useEffect, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, ReactNode } from "react"; +import type { GrainGradientOptions } from "./core.js"; import { - createAndroidCanvasFallbackStyle, - createGrainLayerStyle, - createMeshGradient, - createTurbulenceNoise, - type AndroidCanvasFallback, - type CanvasGrainStyle, - type GrainGradientCSSOptions, -} from "./core.js"; -import { createSwirlTransform, normalizeMotion, normalizeSwirl } from "./internal.js"; - -export type { AndroidCanvasFallback } from "./core.js"; - -export interface GrainGradientReactOptions extends GrainGradientCSSOptions { - androidCanvasFallback?: AndroidCanvasFallback; - androidCanvasFallbackUserAgent?: string | null; -} + createWebGLMeshRenderer, + type WebGLMeshGradientOptions, + type WebGLMeshRenderer, +} from "./webgl.js"; -export interface GrainGradientProps extends GrainGradientReactOptions { +export interface GrainGradientProps extends GrainGradientOptions, WebGLMeshGradientOptions { className?: string; style?: CSSProperties; + canvasStyle?: CSSProperties; children?: ReactNode; } -const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +export { presets } from "./core.js"; +export type { GrainGradientOptions, GrainGradientPreset, GrainGradientPresetName } from "./core.js"; + const createGrainGradientOptionKey = (parts: readonly unknown[]) => JSON.stringify(parts); -export function useGrainGradient(options: GrainGradientReactOptions = {}) { - const meshKey = createGrainGradientOptionKey([ +export function useGrainGradient(options: GrainGradientOptions & WebGLMeshGradientOptions = {}) { + const rootStyle = useMemo( + () => + ({ + position: "relative" as const, + overflow: "hidden" as const, + backgroundColor: options.baseColor ?? "#0b1020", + }) satisfies CSSProperties, + [options.baseColor], + ); + + const canvasStyle = useMemo( + () => + ({ + position: "absolute" as const, + inset: 0, + width: "100%", + height: "100%", + pointerEvents: "none" as const, + }) satisfies CSSProperties, + [], + ); + + return { rootStyle, canvasStyle }; +} + +export const GrainGradient = memo(function GrainGradient(props: GrainGradientProps) { + const { children, className, style, canvasStyle, ...options } = props; + const canvasRef = useRef(null); + const rendererRef = useRef(null); + const optionsRef = useRef(options); + optionsRef.current = options; + const [webglReady, setWebglReady] = useState(false); + const { rootStyle, canvasStyle: defaultCanvasStyle } = useGrainGradient(options); + + const webglKey = createGrainGradientOptionKey([ options.colors, options.baseColor, options.intensity, options.saturation, - options.blur, options.swirl, - ]); - const grainKey = createGrainGradientOptionKey([ + options.motionPreset, + options.motionSpeed, + options.motionIntensity, options.seed, options.frequency, options.baseFrequency, - options.numOctaves, options.contrast, - options.width, - options.height, - options.size, - options.stitchTiles, - ]); - const motionKey = createGrainGradientOptionKey([ - options.motionPreset, - options.motionSpeed, - options.motionIntensity, options.opacity, - options.blur, - options.saturation, - options.intensity, - options.swirl, + options.maxPixelRatio, + options.motionMaxPixelRatio, + options.fps, + options.pauseWhenHidden, ]); - const meshCss = useMemo(() => createMeshGradient(options), [meshKey]); - const grainUrl = useMemo(() => createTurbulenceNoise(options), [grainKey]); - const svgGrainLayerStyle = useMemo(() => createGrainLayerStyle(options), [grainKey]); - const [canvasGrainStyle, setCanvasGrainStyle] = useState(null); useEffect(() => { - setCanvasGrainStyle(createAndroidCanvasFallbackStyle(options)); - }, [grainKey, options.androidCanvasFallback, options.androidCanvasFallbackUserAgent]); - - const usesCanvasFallback = Boolean(canvasGrainStyle); - const activeGrainUrl = canvasGrainStyle?.backgroundImage ?? grainUrl; - const cssText = useMemo(() => `background-image: ${meshCss};`, [meshCss]); - const motion = useMemo(() => normalizeMotion(options), [motionKey]); - const swirl = useMemo(() => normalizeSwirl(options.swirl), [options.swirl]); - const swirlTransform = createSwirlTransform(swirl); - const meshStyle = useMemo( - () => - ({ - backgroundColor: options.baseColor ?? "#0b1020", - backgroundImage: meshCss, - backgroundSize: swirl.enabled - ? `${swirl.backgroundSizeX}% ${swirl.backgroundSizeY}%` - : "100% 100%", - backgroundPosition: swirl.enabled - ? `${swirl.backgroundPositionX}% ${swirl.backgroundPositionY}%` - : undefined, - backgroundRepeat: "no-repeat", - filter: `blur(${clamp(options.blur ?? 42, 0, 80)}px) saturate(${clamp(options.saturation ?? options.intensity ?? 1.18, 0.2, 2.5)})`, - transform: `scale(1.12)${swirlTransform}`, - animation: motion.enabled - ? `grain-gradient-react-mesh-${motion.preset} ${motion.duration}s ease-in-out infinite alternate` - : undefined, - willChange: motion.enabled ? "transform" : undefined, - "--gg-travel": `${motion.travel}%`, - "--gg-zoom": `${motion.zoom}`, - "--gg-rotate": `${motion.rotate}deg`, - "--gg-swirl-x": `${swirl.offsetX}%`, - "--gg-swirl-y": `${swirl.offsetY}%`, - "--gg-swirl-scale": `${swirl.scale}`, - "--gg-swirl-rotate": `${swirl.rotate}deg`, - }) as CSSProperties, - [ - meshCss, - motion, - options.baseColor, - options.blur, - options.intensity, - options.saturation, - swirl, - swirlTransform, - ], - ); - const grainStyle = useMemo( - () => - ({ - backgroundImage: activeGrainUrl, - backgroundSize: canvasGrainStyle?.backgroundSize ?? svgGrainLayerStyle.backgroundSize, - backgroundRepeat: canvasGrainStyle?.backgroundRepeat ?? svgGrainLayerStyle.backgroundRepeat, - imageRendering: canvasGrainStyle?.imageRendering ?? "auto", - opacity: options.opacity ?? 0.2, - mixBlendMode: (options.blendMode ?? "overlay") as CSSProperties["mixBlendMode"], - pointerEvents: "none" as const, - contain: "paint", - }) as CSSProperties, - [ - activeGrainUrl, - usesCanvasFallback, - canvasGrainStyle, - svgGrainLayerStyle, - options.opacity, - options.blendMode, - options.frequency, - options.baseFrequency, - ], - ); - const motionCss = useMemo(() => { - if (!motion.enabled) return ""; - return `@keyframes grain-gradient-react-mesh-drift { 0% { transform: scale(1.12) scale(var(--gg-swirl-scale)) rotate(var(--gg-swirl-rotate)) translate3d(calc(var(--gg-travel) * -1), calc(var(--gg-travel) * -1), 0); } 100% { transform: scale(var(--gg-zoom)) scale(var(--gg-swirl-scale)) rotate(var(--gg-swirl-rotate)) translate3d(var(--gg-travel), var(--gg-travel), 0); } } @keyframes grain-gradient-react-mesh-breathe { 0% { transform: scale(1.12) scale(var(--gg-swirl-scale)) rotate(var(--gg-swirl-rotate)); } 100% { transform: scale(var(--gg-zoom)) scale(var(--gg-swirl-scale)) rotate(var(--gg-swirl-rotate)); } } @keyframes grain-gradient-react-mesh-orbit { 0% { transform: scale(1.12) scale(var(--gg-swirl-scale)) rotate(var(--gg-swirl-rotate)) rotate(calc(var(--gg-rotate) * -1)) translate3d(calc(var(--gg-travel) * -1), var(--gg-travel), 0); } 100% { transform: scale(var(--gg-zoom)) scale(var(--gg-swirl-scale)) rotate(var(--gg-swirl-rotate)) rotate(var(--gg-rotate)) translate3d(var(--gg-travel), calc(var(--gg-travel) * -1), 0); } } @media (prefers-reduced-motion: reduce) { [data-grain-gradient-motion] { animation: none !important; } }`; - }, [motion.enabled]); - const rootStyle = useMemo( - () => ({ position: "relative" as const, overflow: "hidden" as const }), - [], - ); - return { meshStyle, grainStyle, rootStyle, cssText, motionCss }; -} + const canvas = canvasRef.current; + if (!canvas) return; + + let activeRenderer: WebGLMeshRenderer | null = null; + let resizeObserver: ResizeObserver | null = null; + + const stopAndDestroy = () => { + activeRenderer?.destroy(); + activeRenderer = null; + rendererRef.current = null; + }; + + const handleResize = () => activeRenderer?.resize(); + + const createRenderer = () => { + stopAndDestroy(); + const renderer = createWebGLMeshRenderer(canvas, optionsRef.current); + if (!renderer) { + setWebglReady(false); + return; + } + activeRenderer = renderer; + rendererRef.current = renderer; + renderer.resize(); + renderer.start(); + setWebglReady(true); + }; + + const handleContextLost = (event: Event) => { + event.preventDefault(); + stopAndDestroy(); + setWebglReady(false); + }; + + const handleContextRestored = (event: Event) => { + event.preventDefault(); + createRenderer(); + }; + + resizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(handleResize); + resizeObserver?.observe(canvas); + if (!resizeObserver) window.addEventListener("resize", handleResize); + canvas.addEventListener("webglcontextlost", handleContextLost); + canvas.addEventListener("webglcontextrestored", handleContextRestored); + + createRenderer(); + + return () => { + canvas.removeEventListener("webglcontextlost", handleContextLost); + canvas.removeEventListener("webglcontextrestored", handleContextRestored); + resizeObserver?.disconnect(); + if (!resizeObserver) window.removeEventListener("resize", handleResize); + stopAndDestroy(); + setWebglReady(false); + }; + }, []); + + useEffect(() => { + rendererRef.current?.update(options); + }, [webglKey]); -export const GrainGradient = memo(function GrainGradient(props: GrainGradientProps) { - const { children, className, style, ...options } = props; - const { meshStyle, grainStyle, rootStyle, motionCss } = useGrainGradient(options); return (
- {motionCss ?