Create stunning, animated 3D gradients with hardware-accelerated WebGL performance.
Important
As of v0.7, Neat no longer depends on three.js. The rendering engine has been rewritten to use raw WebGL, drastically reducing the total install footprint. Zero external dependencies. If you are upgrading from a previous version, see the Migration Guide below.
β¨ Try the Interactive Editor β¨
Design your perfect gradient with our visual editor, featuring 20+ presets and real-time preview. Export the config and use it in your project instantly.
If you're upgrading from a previous version that used three.js, here's what changed:
| v0.6.x (Three.js) | v0.7+ (Pure WebGL) | |
|---|---|---|
| Library bundle | ~42 KB | ~59 KB (standalone) |
| Three.js peer dep | 616 KB (122 KB gzip) | 0 |
| Total | ~653 KB (~133 KB gzip) | ~59 KB (~17 KB gzip) |
npm uninstall three @types/threeNeat now ships with its own lightweight WebGL renderer β no external 3D library needed.
The mouseDistortionStrength, mouseDistortionRadius, mouseDecayRate, and mouseDarken properties have been removed. You can safely delete them from your config objects β they will be ignored.
All other configuration properties, methods (destroy, downloadAsPNG), and dynamic property setters work exactly the same. Your existing configs from the editor remain fully compatible.
npm install @firecms/neator
yarn add @firecms/neatimport { NeatGradient } from "@firecms/neat";
const gradient = new NeatGradient({
ref: document.getElementById("canvas"),
colors: [
{ color: "#FF5772", enabled: true },
{ color: "#4CB4BB", enabled: true },
{ color: "#FFC600", enabled: true },
{ color: "#8B6AE6", enabled: true },
{ color: "#2E0EC7", enabled: true }
],
speed: 4,
waveAmplitude: 5,
backgroundColor: "#003FFF",
backgroundAlpha: 1
});
// Clean up when done (important for React, Vue, etc.)
gradient.destroy();import { useEffect, useRef } from "react";
import { NeatGradient, NeatConfig } from "@firecms/neat";
function BackgroundGradient() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const gradientRef = useRef<NeatGradient | null>(null);
useEffect(() => {
if (!canvasRef.current) return;
gradientRef.current = new NeatGradient({
ref: canvasRef.current,
colors: [
{ color: "#FF5772", enabled: true },
{ color: "#4CB4BB", enabled: true },
{ color: "#FFC600", enabled: true }
],
speed: 3,
waveAmplitude: 5
});
return () => gradientRef.current?.destroy();
}, []);
return (
<canvas
ref={canvasRef}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: -1
}}
/>
);
}| Property | Type | Default | Range | Description |
|---|---|---|---|---|
speed |
number |
4 |
0-10 |
Animation speed (0 = static) |
waveAmplitude |
number |
3 |
0-10 |
Wave height intensity |
waveFrequencyX |
number |
5 |
0-10 |
Horizontal wave frequency |
waveFrequencyY |
number |
5 |
0-10 |
Vertical wave frequency |
| Property | Type | Default | Description |
|---|---|---|---|
colors |
NeatColor[] |
Required | Array of color objects (up to 6) |
colorBlending |
number |
5 |
How colors mix together (0-10) |
colorBrightness |
number |
1 |
Overall brightness multiplier |
colorSaturation |
number |
0 |
Color saturation adjustment (-10 to 10) |
horizontalPressure |
number |
3 |
Horizontal color distribution (0-10) |
verticalPressure |
number |
3 |
Vertical color distribution (0-10) |
Color Object:
{
color: string; // Hex color (e.g., "#FF5772")
enabled: boolean; // Toggle color on/off
influence?: number; // Color strength (0-1, optional)
}| Property | Type | Default | Description |
|---|---|---|---|
shadows |
number |
4 |
Shadow intensity (0-10) |
highlights |
number |
4 |
Highlight intensity (0-10) |
grainIntensity |
number |
0.55 |
Film grain amount (0-1) |
grainScale |
number |
2 |
Grain size |
grainSparsity |
number |
0.0 |
Grain distribution sparsity (0-1) |
grainSpeed |
number |
0.1 |
Grain animation speed |
wireframe |
boolean |
false |
Show wireframe mesh |
| Property | Type | Default | Description |
|---|---|---|---|
domainWarpEnabled |
boolean |
false |
Enable domain warping distortion |
domainWarpIntensity |
number |
0.5 |
Strength of domain warping |
domainWarpScale |
number |
1.0 |
Spatial frequency scale of warping |
| Property | Type | Default | Description |
|---|---|---|---|
vignetteIntensity |
number |
0.0 |
Darkness intensity at corners (0-1) |
vignetteRadius |
number |
0.8 |
Radial falloff start distance |
| Property | Type | Default | Description |
|---|---|---|---|
fresnelEnabled |
boolean |
false |
Enable glowing outer edge shader effect |
fresnelPower |
number |
2.0 |
Falloff exponent of the rim glow |
fresnelIntensity |
number |
0.5 |
Brightness of the glow effect |
fresnelColor |
string |
"#FFFFFF" |
Color of the rim glow (hex) |
| Property | Type | Default | Description |
|---|---|---|---|
iridescenceEnabled |
boolean |
false |
Enable soap-bubble style color shifting |
iridescenceIntensity |
number |
0.5 |
Strength of the color shift effect |
iridescenceSpeed |
number |
1.0 |
Color cycle speed |
| Property | Type | Default | Description |
|---|---|---|---|
bloomIntensity |
number |
0.0 |
Intensity of the glow bleeding from highlights |
bloomThreshold |
number |
0.7 |
Brightness threshold for bloom candidate pixels |
| Property | Type | Default | Description |
|---|---|---|---|
chromaticAberration |
number |
0.0 |
Lens color channel splitting distance |
| Property | Type | Default | Description |
|---|---|---|---|
shapeType |
'plane' | 'sphere' | 'torus' | 'cylinder' | 'ribbon' |
'plane' |
3D shape geometry to render the gradient on |
shapeRotationX |
number |
0 |
Manual X rotation (radians) |
shapeRotationY |
number |
0 |
Manual Y rotation (radians) |
shapeRotationZ |
number |
0 |
Manual Z rotation (radians) |
shapeAutoRotateSpeedX |
number |
0 |
Auto-rotation speed on X-axis |
shapeAutoRotateSpeedY |
number |
0 |
Auto-rotation speed on Y-axis |
sphereRadius |
number |
15 |
Radius of the sphere shape |
torusRadius |
number |
15 |
Torus primary ring radius |
torusTube |
number |
5 |
Torus inner tube thickness |
cylinderRadius |
number |
10 |
Radius of the cylinder shape |
cylinderHeight |
number |
40 |
Height of the cylinder shape |
planeBend |
number |
0 |
Bending distortion applied to the plane geometry |
planeTwist |
number |
0 |
Twisting distortion applied to the plane geometry |
silhouetteFade |
number |
0.25 |
Edge transparency fade for sphere/torus |
cylinderFade |
number |
0.08 |
Transparency fade towards the ends of the cylinder |
ribbonFade |
number |
0.05 |
Transparency fade towards the ends of the ribbon |
flatShading |
boolean |
true |
Use flat shading for geometry normals |
| Property | Type | Default | Description |
|---|---|---|---|
cameraLock |
boolean |
false |
Lock camera controls and prevent drag rotation |
cameraX |
number |
0 |
Camera offset along X-axis |
cameraY |
number |
0 |
Camera offset along Y-axis |
cameraZ |
number |
0 |
Camera offset along Z-axis |
cameraRotationX |
number |
0 |
Camera pitch rotation (radians) |
cameraRotationY |
number |
0 |
Camera yaw rotation (radians) |
cameraRotationZ |
number |
0 |
Camera roll rotation (radians) |
cameraZoom |
number |
1.0 |
Camera zoom factor |
| Property | Type | Default | Description |
|---|---|---|---|
backgroundColor |
string |
"#FFFFFF" |
Background color (hex) |
backgroundAlpha |
number |
1 |
Background opacity (0-1) |
| Property | Type | Default | Description |
|---|---|---|---|
resolution |
number |
1 |
Mesh density (0.1-2, lower = better performance) |
renderScale |
number |
1 |
Drawing buffer size relative to the canvas' CSS size (0.1-3) |
resolution is mesh density, not pixel resolution. It scales the displacement
grid β 240x240 segments for a plane at 1, 120x120 for the 3D shapes. Most of the
per-frame cost is in the vertex shader, so this is the first thing to turn down.
The grid is also capped to roughly one segment per 6 canvas pixels, so a small
canvas never pays for detail it cannot show.
renderScale is the pixel one. At 0.75 the gradient renders 44% fewer pixels
and the browser scales the result up β usually invisible behind content, and the
cheapest win on low-end devices. It needs the canvas to be sized by CSS; if the
layout size comes from the width/height attributes, scaling is ignored (with a
warning) so the element cannot shrink itself on every resize.
speed: 0 costs nothing. With the clock stopped every frame would be identical,
so the render loop parks itself and only wakes when you change a property. A static
gradient is a one-off render, not a 60fps redraw of the same image.
| Property | Type | Default | Description |
|---|---|---|---|
yOffset |
number |
0 |
Vertical scroll offset |
yOffsetWaveMultiplier |
number |
4 |
How much scroll affects waves (0-20) |
yOffsetColorMultiplier |
number |
4 |
How much scroll affects colors (0-20) |
yOffsetFlowMultiplier |
number |
4 |
How much scroll affects flow field (0-20) |
Example: Parallax Scrolling
window.addEventListener("scroll", () => {
gradient.yOffset = window.scrollY;
});| Property | Type | Default | Description |
|---|---|---|---|
flowEnabled |
boolean |
true |
Enable flow field distortion |
flowDistortionA |
number |
0 |
Primary distortion amplitude |
flowDistortionB |
number |
0 |
Secondary distortion frequency |
flowScale |
number |
1 |
Overall flow field scale |
flowEase |
number |
0 |
Flow field smoothing (0-1) |
| Property | Type | Default | Description |
|---|---|---|---|
enableProceduralTexture |
boolean |
false |
Enable texture overlay |
textureVoidLikelihood |
number |
0.45 |
Gap frequency in texture (0-1) |
textureVoidWidthMin |
number |
200 |
Minimum gap width |
textureVoidWidthMax |
number |
486 |
Maximum gap width |
textureBandDensity |
number |
2.15 |
Texture band density |
textureColorBlending |
number |
0.01 |
Color mixing in texture (0-1) |
textureSeed |
number |
333 |
Random seed for texture |
textureEase |
number |
0.5 |
Flow/Image blend (0=flow, 1=image) |
transparentTextureVoid |
boolean |
false |
Render voids as transparent instead of using proceduralBackgroundColor |
proceduralBackgroundColor |
string |
"#000000" |
Texture void color |
textureShapeTriangles |
number |
20 |
Number of triangle shapes |
textureShapeCircles |
number |
15 |
Number of circle shapes |
textureShapeBars |
number |
15 |
Number of bar shapes |
textureShapeSquiggles |
number |
10 |
Number of squiggle shapes |
Cleans up the WebGL context, event listeners, and removes any injected DOM elements. Call this when the component unmounts to prevent memory leaks (essential for React, Vue, etc.).
gradient.destroy();All properties can be updated in real-time:
// Animation
gradient.speed = 6;
gradient.waveAmplitude = 8;
// Colors
gradient.colors = [
{ color: "#FF0000", enabled: true },
{ color: "#00FF00", enabled: true }
];
// 3D Shape Geometries & Auto-Rotation
gradient.shapeType = "sphere";
gradient.shapeAutoRotateSpeedY = 1.5;
// Advanced Post-Processing Effects
gradient.iridescenceEnabled = true;
gradient.fresnelEnabled = true;
gradient.fresnelColor = "#FF0055";
// Effects
gradient.grainIntensity = 0.5;
// Texture
gradient.enableProceduralTexture = true;
gradient.textureEase = 0.7;-
Lower the mesh density for better FPS:
resolution: 0.5 // A quarter of the vertices
And drop the pixel count too, which costs almost nothing visually:
renderScale: 0.75 // 44% fewer pixels, browser upscales
-
Disable features you don't need:
speed: 0, // Static gradient grainIntensity: 0, // No grain effect flowEnabled: false, // No flow distortion
-
Use fewer colors:
- 3-4 colors = best performance
- 6 colors = more complex but slower
- Subtle animations:
speed: 2-3,waveAmplitude: 3-5 - Dramatic effects:
speed: 5-8,waveAmplitude: 8-10 - Smooth blending: Higher
colorBlendingvalues (7-10) - Sharp contrasts: Lower
colorBlendingvalues (3-5)
Hero Background:
{
colors: [/* your brand colors */],
speed: 3,
waveAmplitude: 5,
shadows: 2,
highlights: 7,
grainIntensity: 0.1
}Subtle Page Background:
{
colors: [/* muted pastels */],
speed: 1,
waveAmplitude: 2,
colorBlending: 9,
backgroundAlpha: 0.7
}Create depth by making different elements move at different speeds:
const gradient = new NeatGradient({
ref: canvas,
colors: [/* ... */],
yOffsetWaveMultiplier: 8, // Waves move faster
yOffsetColorMultiplier: 4, // Colors move slower
yOffsetFlowMultiplier: 6 // Flow in between
});
window.addEventListener("scroll", () => {
gradient.yOffset = window.scrollY;
});Add complex patterns over your gradient:
{
enableProceduralTexture: true,
textureEase: 0.3, // More topographic
textureVoidLikelihood: 0.3, // Fewer gaps
textureBandDensity: 1.5, // Wider bands
textureShapeTriangles: 30, // More shapes
proceduralBackgroundColor: "#000033" // Dark voids
}Browsers only grant a handful of live WebGL contexts, and every extra NeatGradient
runs its own shader. So don't create one per card β create one gradient and mirror
it into as many plain 2D canvases as you like. Each mirror can show a different crop,
they all stay perfectly in sync, and the cost per mirror is a GPU copy instead of a
second render.
This is exactly how the editor previews a gradient as a website hero, a phone screen and a row of avatars at the same time.
The source gradient needs preserveDrawingBuffer: true β without it the drawing buffer
is cleared after compositing and there is nothing left to copy:
import { NeatGradient } from "@firecms/neat";
const source = document.getElementById("source") as HTMLCanvasElement;
const gradient = new NeatGradient({
ref: source,
preserveDrawingBuffer: true, // required to read the canvas from outside its own frame
colors: [
{ color: "#FF5772", enabled: true },
{ color: "#4CB4BB", enabled: true },
{ color: "#FFC600", enabled: true }
]
});Then mirror regions of it wherever you want:
type MirrorOptions = {
cx?: number; // horizontal centre of the crop, 0β1
cy?: number; // vertical centre of the crop, 0β1
zoom?: number; // 1 shows as much as fits, 2 shows half
fps?: number; // refresh rate β drop it for small decorative mirrors
};
function mirror(target: HTMLCanvasElement, options: MirrorOptions = {}) {
const { cx = 0.5, cy = 0.5, zoom = 1, fps = 60 } = options;
const ctx = target.getContext("2d");
const interval = 1000 / fps;
let raf = 0;
let last = 0;
const draw = (now: number) => {
raf = requestAnimationFrame(draw);
if (!ctx || now - last < interval) return;
last = now;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.round(target.clientWidth * dpr);
const h = Math.round(target.clientHeight * dpr);
if (!w || !h || !source.width || !source.height) return;
if (target.width !== w || target.height !== h) {
target.width = w;
target.height = h;
}
// Largest crop of the source matching the target's aspect ratio, then zoomed
const aspect = w / h;
let cropW = source.width;
let cropH = source.width / aspect;
if (cropH > source.height) {
cropH = source.height;
cropW = source.height * aspect;
}
cropW /= zoom;
cropH /= zoom;
const sx = Math.min(Math.max(cx * source.width - cropW / 2, 0), source.width - cropW);
const sy = Math.min(Math.max(cy * source.height - cropH / 2, 0), source.height - cropH);
ctx.clearRect(0, 0, w, h);
ctx.drawImage(source, sx, sy, cropW, cropH, 0, 0, w, h);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}
// A hero, a tight crop for a card, and a slow-refreshing avatar
mirror(document.getElementById("hero") as HTMLCanvasElement);
mirror(document.getElementById("card") as HTMLCanvasElement, { cx: 0.3, cy: 0.4, zoom: 1.7 });
mirror(document.getElementById("avatar") as HTMLCanvasElement, { cx: 0.7, cy: 0.6, zoom: 10, fps: 10 });Things worth knowing
- The source canvas must stay laid out.
display: nonecollapses it to zero size and the gradient stops rendering β hide it withposition: fixed; inset: 0; z-index: -1behind your content, or cover it with an opaque layer, instead. - Use one
requestAnimationFrameloop for all mirrors rather than one each; the example above is per-mirror for clarity, but a shared ticker iterating a list scales better. drawImagefrom a WebGL canvas is a cross-context copy β cheap on desktop, noticeable on phones. There, cap the device pixel ratio at 1 and run mirrors at 30 fps, and give small decorative ones (avatars, icons) 10 fps. Nobody can tell on a 40px circle.- Mirrors are ordinary canvases, so they take
border-radius,mask-image,filterand anything else CSS offers. A tiny mirror blurred to nothing makes a good ambient glow behind a layout. - Only the source needs
preserveDrawingBuffer. It is also what makesdownloadAsPNG()and video capture work.
Full TypeScript definitions included:
import { NeatGradient, NeatConfig, NeatColor, NeatController } from "@firecms/neat";
const config: NeatConfig = {
// ... fully typed config
};
const gradient: NeatController = new NeatGradient(config);Neat uses custom WebGL shaders to render dynamic 3D gradients entirely on the GPU:
- Mesh Generation: Creates a subdivided plane geometry
- Vertex Shader: Displaces vertices to create waves using Perlin noise
- Fragment Shader: Blends colors, applies flow fields, lighting, and effects
- Hardware Acceleration: All computations run on the GPU for smooth 60fps animations
The result is a performant, beautiful gradient that can run on any modern device.
Neat is released under the MIT License + The Commons Clause.
You can:
- β Use freely in personal projects
- β Use freely in commercial projects (e.g. SaaS landing pages, company websites)
- β Modify and redistribute (with attribution)
- β Use in open-source projects
You CANNOT:
- β Sell the software
- β Include it in a paid template or theme builder that you sell
- β Offer the software as a paid service
Purchase a license key for β¬12 one-time (per domain) to remove the NEAT watermark and console branding.
Then pass the key in your config:
const gradient = new NeatGradient({
ref: canvas,
colors: [...],
licenseKey: "NEAT-eyJ0eXBlI..." // Your license key
});Each key is locked to the domain you specify at checkout (subdomains included). Development on localhost always works without a key.
Created by FireCMS with β€οΈ
Found a bug or have a feature request?
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- π Website & Editor
- π€ llms.txt β the whole config reference in one file, for coding agents
- π¦ npm Package
- π» GitHub Repository
- π¬ Discord Community
Made with β¨ by the FireCMS team
