A four-stop linear or radial repeating gradient whose color stops travel continuously along the gradient line, finished with an SVG feTurbulence film-grain overlay and soft depth lighting.
Preview
Grain Gradient
Shifting color with a tactile film-grain finish.
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add grain-gradientWrites components/ui/grain-gradient.tsx and components/styles/grain-gradient.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { GrainGradient } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <GrainGradient />6 );7}Props
Accessibility
The gradient, lighting and SVG grain (focusable=false) layers are aria-hidden with pointer-events: none. The stop animation runs only while visible and speed > 0; prefers-reduced-motion shows the static starting gradient.
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/grain-gradient.module.css";5 6export interface GrainGradientProps {7 /** First gradient stop color */8 color1?: string;9 /** Second gradient stop color */10 color2?: string;11 /** Third gradient stop color */12 color3?: string;13 /** Fourth gradient stop color */14 color4?: string;15 /** Gradient shape */16 type?: "linear" | "radial";17 /** Direction of the linear gradient in degrees (ignored for radial) */18 angle?: number;19 /** Speed at which the stops travel along the gradient (1 = default, 0 = paused) */20 speed?: number;21 /** Opacity of the film-grain overlay (0–1) */22 grainOpacity?: number;23 /** Height of the background in px */24 height?: number;25 /** Corner radius in px */26 borderRadius?: number;27 /** Foreground content rendered centered on top */28 children?: React.ReactNode;29 /** Optional className merged onto the root */30 className?: string;31 /** Inline styles merged onto the root */32 style?: React.CSSProperties;33}34 35/** Length of one full color cycle, as a percentage of the gradient line */36const PERIOD = 180;37/** Seconds per full cycle at speed 1 */38const CYCLE_S = 16;39 40function buildGradient(41 type: "linear" | "radial",42 angle: number,43 colors: string[],44 phase: number,45): string {46 const n = colors.length;47 const stops: string[] = [];48 for (let i = 0; i <= n; i++) {49 const pos = ((i / n - phase) * PERIOD).toFixed(2);50 stops.push(`${colors[i % n]} ${pos}%`);51 }52 const list = stops.join(", ");53 return type === "radial"54 ? `repeating-radial-gradient(circle at 28% 22%, ${list})`55 : `repeating-linear-gradient(${angle}deg, ${list})`;56}57 58function useReducedMotionPref() {59 const [reduced, setReduced] = React.useState(false);60 React.useEffect(() => {61 if (typeof window === "undefined" || !window.matchMedia) return;62 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");63 const update = () => setReduced(mq.matches);64 update();65 mq.addEventListener("change", update);66 return () => mq.removeEventListener("change", update);67 }, []);68 return reduced;69}70 71function useInView(ref: React.RefObject<HTMLElement | null>) {72 const [inView, setInView] = React.useState(true);73 React.useEffect(() => {74 const el = ref.current;75 if (!el || typeof IntersectionObserver === "undefined") return;76 const io = new IntersectionObserver((entries) => {77 const entry = entries[entries.length - 1];78 if (entry) setInView(entry.isIntersecting);79 }, { rootMargin: "80px" });80 io.observe(el);81 return () => io.disconnect();82 }, [ref]);83 return inView;84}85 86export function GrainGradient({87 color1 = "#f97316",88 color2 = "#db2777",89 color3 = "#7c3aed",90 color4 = "#0ea5e9",91 type = "linear",92 angle = 135,93 speed = 1,94 grainOpacity = 0.35,95 height = 360,96 borderRadius = 20,97 children,98 className = "",99 style,100}: GrainGradientProps) {101 const rootRef = React.useRef<HTMLDivElement>(null);102 const layerRef = React.useRef<HTMLDivElement>(null);103 const phaseRef = React.useRef(0);104 const reduced = useReducedMotionPref();105 const inView = useInView(rootRef);106 const filterId = `mf-gg-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;107 const safeSpeed = Math.max(0, speed);108 const running = !reduced && safeSpeed > 0 && inView;109 const colors = React.useMemo(() => [color1, color2, color3, color4], [color1, color2, color3, color4]);110 111 React.useEffect(() => {112 const layer = layerRef.current;113 if (!layer || !running) return;114 let raf = 0;115 let last = performance.now();116 let acc = 0;117 const tick = (now: number) => {118 const dt = Math.min(0.05, (now - last) / 1000);119 last = now;120 phaseRef.current = (phaseRef.current + (dt * safeSpeed) / CYCLE_S) % 1;121 acc += dt;122 // ~40fps is plenty for a slow gradient drift and halves style recalcs123 if (acc >= 0.025) {124 acc = 0;125 layer.style.backgroundImage = buildGradient(type, angle, colors, phaseRef.current);126 }127 raf = requestAnimationFrame(tick);128 };129 raf = requestAnimationFrame(tick);130 return () => cancelAnimationFrame(raf);131 }, [running, safeSpeed, type, angle, colors]);132 133 const styleVars = {134 "--mf-gg-grain": String(Math.max(0, Math.min(1, grainOpacity))),135 "--mf-gg-glow": color2,136 height: `${height}px`,137 borderRadius: `${borderRadius}px`,138 ...style,139 } as React.CSSProperties;140 141 return (142 <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>143 <div144 ref={layerRef}145 aria-hidden="true"146 className={styles.gradient}147 style={{ backgroundImage: buildGradient(type, angle, colors, 0) }}148 />149 <div aria-hidden="true" className={styles.depth} />150 <svg aria-hidden="true" focusable="false" className={styles.grain} preserveAspectRatio="none">151 <filter id={filterId} x="0" y="0" width="100%" height="100%">152 <feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="3" stitchTiles="stitch" />153 <feColorMatrix type="saturate" values="0" />154 <feComponentTransfer>155 <feFuncA type="linear" slope="1.6" intercept="-0.25" />156 </feComponentTransfer>157 </filter>158 <rect width="100%" height="100%" filter={`url(#${filterId})`} />159 </svg>160 <div className={styles.content}>161 {children ?? (162 <div className={styles.copy}>163 <h3 className={styles.title}>Grain Gradient</h3>164 <p className={styles.subtitle}>Shifting color with a tactile film-grain finish.</p>165 </div>166 )}167 </div>168 </div>169 );170}