ShadersGradient Backgroundsno dependencies

LiquidGradient

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 props
1
1
0.6
360
20

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add liquid-gradient

Writes 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.

example.tsx
1import { LiquidGradient } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <LiquidGradient />6  );7}

Props

PropTypeDefault
color1

First fluid color (hex).

string'#ff4d6d'
color2

Second fluid color (hex).

string'#ffb703'
color3

Third fluid color (hex).

string'#3a86ff'
color4

Fourth fluid color (hex).

string'#8338ec'
speed

Flow speed multiplier (0 pauses).

number1
scale

Size of the fluid features; higher = larger, calmer pools (0.3–3).

number1
softness

Edge softness between colors; 0 = sharp cells, 1 = fully blended.

number0.6
height

Height of the background in px.

number360
borderRadius

Corner radius in px.

number20
children

Content rendered centered on top; defaults to a heading and subline.

React.ReactNodeundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/liquid-gradient.tsx
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}