A Canvas 2D fluid color field: eight Lissajous attractors blended by inverse-distance weighting through a domain-warped coordinate space, rendered at low resolution and smoothly upscaled for a liquid look.
Preview
Liquid Gradient
A warped color field that pours and pools in real time.
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add liquid-gradientWrites components/ui/liquid-gradient.tsx and components/styles/liquid-gradient.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { LiquidGradient } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <LiquidGradient />6 );7}Props
Accessibility
The canvas and sheen are aria-hidden decorative layers; text sits above in normal flow. The render loop stops off-screen or at speed 0, and prefers-reduced-motion draws a single static frame. Resizes via ResizeObserver with devicePixelRatio capped at 2.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/liquid-gradient.module.css";5 6export interface LiquidGradientProps {7 /** First fluid color */8 color1?: string;9 /** Second fluid color */10 color2?: string;11 /** Third fluid color */12 color3?: string;13 /** Fourth fluid color */14 color4?: string;15 /** Flow speed multiplier (1 = default, 0 = paused) */16 speed?: number;17 /** Size of the fluid features (higher = larger, calmer pools) */18 scale?: number;19 /** Edge softness between colors (0 = sharp cells, 1 = fully blended) */20 softness?: number;21 /** Height of the background in px */22 height?: number;23 /** Corner radius in px */24 borderRadius?: number;25 /** Foreground content rendered centered on top */26 children?: React.ReactNode;27 /** Optional className merged onto the root */28 className?: string;29 /** Inline styles merged onto the root */30 style?: React.CSSProperties;31}32 33type RGB = [number, number, number];34 35/** Two attractors per color, each on its own Lissajous loop */36const BLOBS = [37 { c: 0, ax: 0.32, ay: 0.3, fx: 0.21, fy: 0.17, px: 0.0, py: 1.2 },38 { c: 1, ax: 0.36, ay: 0.32, fx: 0.15, fy: 0.23, px: 2.1, py: 0.4 },39 { c: 2, ax: 0.3, ay: 0.36, fx: 0.19, fy: 0.13, px: 4.0, py: 2.9 },40 { c: 3, ax: 0.34, ay: 0.28, fx: 0.12, fy: 0.2, px: 5.3, py: 4.4 },41 { c: 0, ax: 0.28, ay: 0.34, fx: 0.17, fy: 0.11, px: 3.3, py: 5.6 },42 { c: 1, ax: 0.38, ay: 0.3, fx: 0.11, fy: 0.19, px: 1.0, py: 3.7 },43 { c: 2, ax: 0.26, ay: 0.38, fx: 0.23, fy: 0.15, px: 5.9, py: 0.9 },44 { c: 3, ax: 0.36, ay: 0.26, fx: 0.14, fy: 0.24, px: 2.7, py: 2.2 },45];46 47function hexToRgb(hex: string, fallback: RGB): RGB {48 const m = hex.trim().replace(/^#/, "");49 const full = m.length === 3 ? m.split("").map((ch) => ch + ch).join("") : m;50 if (!/^[0-9a-fA-F]{6}$/.test(full)) return fallback;51 const n = parseInt(full, 16);52 return [(n >> 16) & 255, (n >> 8) & 255, n & 255];53}54 55function useReducedMotionPref() {56 const [reduced, setReduced] = React.useState(false);57 React.useEffect(() => {58 if (typeof window === "undefined" || !window.matchMedia) return;59 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");60 const update = () => setReduced(mq.matches);61 update();62 mq.addEventListener("change", update);63 return () => mq.removeEventListener("change", update);64 }, []);65 return reduced;66}67 68function useInView(ref: React.RefObject<HTMLElement | null>) {69 const [inView, setInView] = React.useState(true);70 React.useEffect(() => {71 const el = ref.current;72 if (!el || typeof IntersectionObserver === "undefined") return;73 const io = new IntersectionObserver((entries) => {74 const entry = entries[entries.length - 1];75 if (entry) setInView(entry.isIntersecting);76 }, { rootMargin: "80px" });77 io.observe(el);78 return () => io.disconnect();79 }, [ref]);80 return inView;81}82 83export function LiquidGradient({84 color1 = "#ff4d6d",85 color2 = "#ffb703",86 color3 = "#3a86ff",87 color4 = "#8338ec",88 speed = 1,89 scale = 1,90 softness = 0.6,91 height = 360,92 borderRadius = 20,93 children,94 className = "",95 style,96}: LiquidGradientProps) {97 const rootRef = React.useRef<HTMLDivElement>(null);98 const canvasRef = React.useRef<HTMLCanvasElement>(null);99 const timeRef = React.useRef(0);100 const reduced = useReducedMotionPref();101 const inView = useInView(rootRef);102 const safeSpeed = Math.max(0, speed);103 const safeScale = Math.max(0.3, Math.min(3, scale));104 const safeSoft = Math.max(0, Math.min(1, softness));105 106 React.useEffect(() => {107 const root = rootRef.current;108 const canvas = canvasRef.current;109 if (!root || !canvas) return;110 const ctx = canvas.getContext("2d");111 if (!ctx) return;112 const buffer = document.createElement("canvas");113 const bctx = buffer.getContext("2d");114 if (!bctx) return;115 116 const palette: RGB[] = [117 hexToRgb(color1, [255, 77, 109]),118 hexToRgb(color2, [255, 183, 3]),119 hexToRgb(color3, [58, 134, 255]),120 hexToRgb(color4, [131, 56, 236]),121 ];122 // softness maps to the inverse-distance exponent: low exponent = wide blends, high = crisp cells123 const power = 4.2 - safeSoft * 3;124 const warp = 0.09 / safeScale;125 const freq = 3.2 / safeScale;126 127 let cssW = 0;128 let cssH = 0;129 let bw = 0;130 let bh = 0;131 let image: ImageData | null = null;132 133 const resize = () => {134 const rect = root.getBoundingClientRect();135 // the canvas bleeds 12px past each edge to hide the blur fringe136 cssW = Math.max(1, rect.width + 24);137 cssH = Math.max(1, rect.height + 24);138 const dpr = Math.min(2, window.devicePixelRatio || 1);139 canvas.width = Math.round(cssW * dpr);140 canvas.height = Math.round(cssH * dpr);141 // low-resolution field (~1 sample per 7 css px), upscaled with smoothing for a liquid look142 bw = Math.max(16, Math.round(cssW / 7));143 bh = Math.max(10, Math.round(cssH / 7));144 buffer.width = bw;145 buffer.height = bh;146 image = bctx.createImageData(bw, bh);147 };148 149 const render = (t: number) => {150 if (!image) return;151 const data = image.data;152 const aspect = cssW / cssH;153 const pos = BLOBS.map((b) => [154 0.5 + Math.sin(t * b.fx + b.px) * b.ax * Math.max(1, aspect * 0.8),155 0.5 + Math.cos(t * b.fy + b.py) * b.ay,156 ]);157 let o = 0;158 for (let y = 0; y < bh; y++) {159 const v = y / (bh - 1);160 for (let x = 0; x < bw; x++) {161 const u = (x / (bw - 1)) * aspect;162 // domain warp for the liquid swirl163 const wu = u + Math.sin(v * freq + t * 0.6) * warp + Math.sin((u + v) * freq * 0.7 - t * 0.4) * warp * 0.6;164 const wv = v + Math.cos(u * freq * 0.9 - t * 0.5) * warp + Math.cos((u - v) * freq * 0.6 + t * 0.3) * warp * 0.6;165 let r = 0;166 let g = 0;167 let bl = 0;168 let wsum = 0;169 for (let i = 0; i < BLOBS.length; i++) {170 const dx = wu - pos[i][0];171 const dy = wv - pos[i][1];172 const w = 1 / Math.pow(dx * dx + dy * dy + 0.004, power * 0.5);173 const c = palette[BLOBS[i].c];174 r += c[0] * w;175 g += c[1] * w;176 bl += c[2] * w;177 wsum += w;178 }179 data[o] = r / wsum;180 data[o + 1] = g / wsum;181 data[o + 2] = bl / wsum;182 data[o + 3] = 255;183 o += 4;184 }185 }186 bctx.putImageData(image, 0, 0);187 ctx.imageSmoothingEnabled = true;188 ctx.imageSmoothingQuality = "high";189 ctx.drawImage(buffer, 0, 0, canvas.width, canvas.height);190 };191 192 resize();193 render(timeRef.current);194 195 const ro = new ResizeObserver(() => {196 resize();197 render(timeRef.current);198 });199 ro.observe(root);200 201 let raf = 0;202 if (!reduced && safeSpeed > 0 && inView) {203 let last = performance.now();204 const tick = (now: number) => {205 const dt = Math.min(0.05, (now - last) / 1000);206 last = now;207 timeRef.current += dt * safeSpeed;208 render(timeRef.current);209 raf = requestAnimationFrame(tick);210 };211 raf = requestAnimationFrame(tick);212 }213 214 return () => {215 ro.disconnect();216 cancelAnimationFrame(raf);217 };218 }, [color1, color2, color3, color4, safeSpeed, safeScale, safeSoft, reduced, inView]);219 220 const styleVars = {221 "--mf-lg-soft": `${(safeSoft * 10).toFixed(1)}px`,222 "--mf-lg-fallback": `linear-gradient(120deg, ${color1}, ${color2}, ${color3}, ${color4})`,223 height: `${height}px`,224 borderRadius: `${borderRadius}px`,225 ...style,226 } as React.CSSProperties;227 228 return (229 <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>230 <canvas ref={canvasRef} aria-hidden="true" className={styles.canvas} />231 <div aria-hidden="true" className={styles.sheen} />232 <div className={styles.content}>233 {children ?? (234 <div className={styles.copy}>235 <h3 className={styles.title}>Liquid Gradient</h3>236 <p className={styles.subtitle}>A warped color field that pours and pools in real time.</p>237 </div>238 )}239 </div>240 </div>241 );242}