ShadersGradient Backgroundsno dependencies

WaveGradient

Stripe-style stacked bands of gradient color drawn on Canvas 2D, each layer undulating on two summed sine waves with a fine highlight ridge, over a tilted field with configurable layer count and amplitude.

Preview

Wave Gradient

Layered bands of color rolling in slow motion.

Customize

12 props
1
38
4
-12
0
360
20

Installation

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

npx motionfoundry add wave-gradient

Writes components/ui/wave-gradient.tsx and components/styles/wave-gradient.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

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

Props

PropTypeDefault
color1

First wave color.

string'#a960ee'
color2

Second wave color.

string'#ff333d'
color3

Third wave color.

string'#90e0ff'
color4

Fourth wave color.

string'#ffcb57'
backgroundColor

Surface color behind the waves.

string'#0b0a1a'
speed

Undulation speed multiplier (0 pauses).

number1
amplitude

Wave amplitude in px at 360px height (scales with height).

number38
layers

Number of stacked wave layers, clamped to 2–6.

number4
angle

Tilt of the wave field in degrees.

number-12
blur

Softening blur applied to the waves in px.

number0
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 shading overlay are aria-hidden; children render above in normal reading order. The loop pauses when off-screen or at speed 0; prefers-reduced-motion paints one static frame. Handles devicePixelRatio and container resizes via ResizeObserver.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/wave-gradient.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/wave-gradient.module.css";5 6export interface WaveGradientProps {7  /** First wave color */8  color1?: string;9  /** Second wave color */10  color2?: string;11  /** Third wave color */12  color3?: string;13  /** Fourth wave color */14  color4?: string;15  /** Surface color behind the waves */16  backgroundColor?: string;17  /** Undulation speed multiplier (1 = default, 0 = paused) */18  speed?: number;19  /** Wave amplitude in px (at the default 360px height) */20  amplitude?: number;21  /** Number of stacked wave layers (2–6) */22  layers?: number;23  /** Tilt of the wave field in degrees */24  angle?: number;25  /** Softening blur applied to the waves, in px */26  blur?: number;27  /** Height of the background in px */28  height?: number;29  /** Corner radius in px */30  borderRadius?: number;31  /** Foreground content rendered centered on top */32  children?: React.ReactNode;33  /** Optional className merged onto the root */34  className?: string;35  /** Inline styles merged onto the root */36  style?: React.CSSProperties;37}38 39function useReducedMotionPref() {40  const [reduced, setReduced] = React.useState(false);41  React.useEffect(() => {42    if (typeof window === "undefined" || !window.matchMedia) return;43    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");44    const update = () => setReduced(mq.matches);45    update();46    mq.addEventListener("change", update);47    return () => mq.removeEventListener("change", update);48  }, []);49  return reduced;50}51 52function useInView(ref: React.RefObject<HTMLElement | null>) {53  const [inView, setInView] = React.useState(true);54  React.useEffect(() => {55    const el = ref.current;56    if (!el || typeof IntersectionObserver === "undefined") return;57    const io = new IntersectionObserver((entries) => {58      const entry = entries[entries.length - 1];59      if (entry) setInView(entry.isIntersecting);60    }, { rootMargin: "80px" });61    io.observe(el);62    return () => io.disconnect();63  }, [ref]);64  return inView;65}66 67export function WaveGradient({68  color1 = "#a960ee",69  color2 = "#ff333d",70  color3 = "#90e0ff",71  color4 = "#ffcb57",72  backgroundColor = "#0b0a1a",73  speed = 1,74  amplitude = 38,75  layers = 4,76  angle = -12,77  blur = 0,78  height = 360,79  borderRadius = 20,80  children,81  className = "",82  style,83}: WaveGradientProps) {84  const rootRef = React.useRef<HTMLDivElement>(null);85  const canvasRef = React.useRef<HTMLCanvasElement>(null);86  const timeRef = React.useRef(0);87  const reduced = useReducedMotionPref();88  const inView = useInView(rootRef);89  const safeSpeed = Math.max(0, speed);90  const layerCount = Math.max(2, Math.min(6, Math.round(layers)));91 92  React.useEffect(() => {93    const root = rootRef.current;94    const canvas = canvasRef.current;95    if (!root || !canvas) return;96    const ctx = canvas.getContext("2d");97    if (!ctx) return;98    const colors = [color1, color2, color3, color4];99    const rad = (angle * Math.PI) / 180;100    let w = 0;101    let h = 0;102    let dpr = 1;103 104    const resize = () => {105      const rect = root.getBoundingClientRect();106      w = Math.max(1, rect.width);107      h = Math.max(1, rect.height);108      dpr = Math.min(2, window.devicePixelRatio || 1);109      canvas.width = Math.round(w * dpr);110      canvas.height = Math.round(h * dpr);111    };112 113    const render = (t: number) => {114      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);115      ctx.globalAlpha = 1;116      ctx.fillStyle = backgroundColor;117      ctx.fillRect(0, 0, w, h);118      const d = Math.hypot(w, h);119      const amp = amplitude * (h / 360);120      ctx.save();121      ctx.translate(w / 2, h / 2);122      ctx.rotate(rad);123      const left = -d / 2;124      const right = d / 2;125      const step = Math.max(6, d / 140);126      for (let i = 0; i < layerCount; i++) {127        const k = layerCount === 1 ? 0 : i / (layerCount - 1);128        const baseY = -h * 0.2 + k * h * 0.5;129        const a = amp * (1 - k * 0.35);130        const f1 = (1.6 + i * 0.35) / d;131        const f2 = (3.1 + i * 0.5) / d;132        const ph = i * 1.7;133        const grad = ctx.createLinearGradient(left, 0, right, 0);134        grad.addColorStop(0, colors[i % 4]);135        grad.addColorStop(0.5, colors[(i + 1) % 4]);136        grad.addColorStop(1, colors[(i + 2) % 4]);137        ctx.beginPath();138        for (let x = left; x <= right + step; x += step) {139          const y =140            baseY +141            Math.sin(x * f1 * Math.PI * 2 + t * (0.55 + i * 0.12) + ph) * a * 0.65 +142            Math.sin(x * f2 * Math.PI * 2 - t * (0.4 + i * 0.09) + ph * 1.3) * a * 0.35;143          if (x === left) ctx.moveTo(x, y);144          else ctx.lineTo(x, y);145        }146        // top ridge highlight147        ctx.strokeStyle = "rgba(255,255,255,0.22)";148        ctx.lineWidth = 1.25;149        ctx.globalAlpha = 1;150        ctx.stroke();151        ctx.lineTo(right + step, d);152        ctx.lineTo(left, d);153        ctx.closePath();154        ctx.globalAlpha = 0.78 + k * 0.12;155        ctx.fillStyle = grad;156        ctx.fill();157      }158      ctx.restore();159      ctx.globalAlpha = 1;160    };161 162    resize();163    render(timeRef.current);164 165    const ro = new ResizeObserver(() => {166      resize();167      render(timeRef.current);168    });169    ro.observe(root);170 171    let raf = 0;172    if (!reduced && safeSpeed > 0 && inView) {173      let last = performance.now();174      const tick = (now: number) => {175        const dt = Math.min(0.05, (now - last) / 1000);176        last = now;177        timeRef.current += dt * safeSpeed;178        render(timeRef.current);179        raf = requestAnimationFrame(tick);180      };181      raf = requestAnimationFrame(tick);182    }183 184    return () => {185      ro.disconnect();186      cancelAnimationFrame(raf);187    };188  }, [color1, color2, color3, color4, backgroundColor, amplitude, layerCount, angle, safeSpeed, reduced, inView]);189 190  const styleVars = {191    "--mf-wg-bg": backgroundColor,192    "--mf-wg-blur": `${Math.max(0, blur)}px`,193    height: `${height}px`,194    borderRadius: `${borderRadius}px`,195    ...style,196  } as React.CSSProperties;197 198  return (199    <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>200      <canvas ref={canvasRef} aria-hidden="true" className={styles.canvas} />201      <div aria-hidden="true" className={styles.shade} />202      <div className={styles.content}>203        {children ?? (204          <div className={styles.copy}>205            <h3 className={styles.title}>Wave Gradient</h3>206            <p className={styles.subtitle}>Layered bands of color rolling in slow motion.</p>207          </div>208        )}209      </div>210    </div>211  );212}