Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 3 additions & 28 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="0; url=./playground/">
<title>grain-gradient playground</title>
<meta name="description" content="Tune lightweight mesh gradients with SVG turbulence grain.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="grain-gradient">
<meta property="og:title" content="grain-gradient playground">
<meta property="og:description" content="Tune lightweight mesh gradients with SVG turbulence grain.">
<meta property="og:url" content="https://aomona.github.io/grain-gradient/">
<meta property="og:image" content="https://aomona.github.io/grain-gradient/screenshot.png">
<meta property="og:image:alt" content="grain-gradient playground showing a grainy mesh gradient preview and controls.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="grain-gradient playground">
<meta name="twitter:description" content="Tune lightweight mesh gradients with SVG turbulence grain.">
<meta name="twitter:image" content="https://aomona.github.io/grain-gradient/screenshot.png">
</head>
<body>
<a href="./playground/">Open playground</a>
</body>
</html>
HTML
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b
- uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 20
node-version: 20.19.0
cache: npm

- run: npm ci
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
dist
dist-playground
.DS_Store
.slim/deepwork/
123 changes: 41 additions & 82 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,131 +1,92 @@
# 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

```bash
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 (
<GrainGradient
colors={["#c2e812", "#ff7f11", "#ee4266", "#2a1e5c"]}
{...presets["Aurora Citrus"]}
motionPreset="drift"
motionSpeed={38}
motionIntensity={46}
swirl={30}
androidCanvasFallback="auto"
opacity={0.22}
style={{ minHeight: "100vh" }}
/>
);
}
```

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 `<canvas>` 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 <GrainGradient androidCanvasFallback="auto" androidCanvasFallbackUserAgent={userAgent} />;
}
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 `<canvas>` 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 (
<WebGLGrainGradient
colors={["#c2e812", "#ff7f11", "#ee4266", "#2a1e5c"]}
motionPreset="drift"
motionSpeed={38}
motionIntensity={46}
// Caps static canvas pixel density for high-DPI performance.
maxPixelRatio={1.25}
// Motion defaults are already lightweight: fps=30, motionMaxPixelRatio=0.75.
style={{ minHeight: "100vh" }}
/>
);
}
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
<WebGLGrainGradient motionPreset="drift" motionSpeed={35} fps={30} motionMaxPixelRatio={0.75} />
```
- `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

Expand All @@ -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/`.
Loading
Loading