ShadersGradient Backgroundsno dependencies

GrainGradient

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

Installation

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

npx motionfoundry add grain-gradient

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

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

Props

PropTypeDefault
color1

First gradient stop color.

string'#f97316'
color2

Second gradient stop color (also tints the depth glow).

string'#db2777'
color3

Third gradient stop color.

string'#7c3aed'
color4

Fourth gradient stop color.

string'#0ea5e9'
type

Gradient shape.

"linear" | "radial"'linear'
angle

Direction of the linear gradient in degrees (ignored for radial).

number135
speed

Speed at which stops travel along the gradient; 1 = one cycle per 16s, 0 pauses.

number1
grainOpacity

Opacity of the film-grain overlay (0–1).

number0.35
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 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

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