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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add wave-gradientWrites 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.
1import { WaveGradient } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <WaveGradient />6 );7}Props
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
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}