ShadersGradient Backgroundsno dependencies

SilkGradient

A raw WebGL satin shader: a warped multi-sine height field is lit per pixel with diffuse shading, a tight specular highlight and a broad sheen lobe, so dark fabric folds ripple and catch light. Falls back to blurred, skewing CSS fold bands when WebGL is unavailable.

Preview

Silk Gradient

Dark satin folds catching a slow specular sheen.

Customize

7 props
1
1
1

Installation

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

npx motionfoundry add silk-gradient

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

Usage

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

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

Props

PropTypeDefault
color1

Primary fabric color (hex).

string'#6d28d9'
color2

Secondary fabric color blended across the folds (hex).

string'#1e40af'
highlightColor

Specular sheen color on the fold crests (hex).

string'#f5d0fe'
backgroundColor

Shadow color deep in the folds (hex).

string'#030306'
speed

Flow speed multiplier (0 pauses).

number1
foldScale

Size of the folds; higher = broader, softer drapes (0.3–3).

number1
sheen

Specular sheen strength, 0 (matte) to 2 (glossy).

number1
height

Height of the background in px.

number380
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 (or CSS fallback) is an aria-hidden decorative layer; children render above with light, shadowed text. Animation stops off-screen, at speed 0, and under prefers-reduced-motion (one static frame). GL resources are deleted on unmount and a lost context switches to the CSS fallback.

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

Source

components/ui/silk-gradient.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/silk-gradient.module.css";5 6export interface SilkGradientProps {7  /** Primary fabric color */8  color1?: string;9  /** Secondary fabric color blended across the folds */10  color2?: string;11  /** Specular sheen color on the fold crests */12  highlightColor?: string;13  /** Shadow color deep in the folds */14  backgroundColor?: string;15  /** Flow speed multiplier (1 = default, 0 = paused) */16  speed?: number;17  /** Size of the folds (higher = broader, softer drapes) */18  foldScale?: number;19  /** Specular sheen strength (0 = matte, 2 = glossy) */20  sheen?: 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];34type UniformMap = Record<string, number | readonly number[]>;35 36const FRAG = `37#ifdef GL_FRAGMENT_PRECISION_HIGH38precision highp float;39#else40precision mediump float;41#endif42uniform vec2 u_res; uniform float u_time;43uniform vec3 u_c1; uniform vec3 u_c2; uniform vec3 u_hl; uniform vec3 u_bg;44uniform float u_fold; uniform float u_sheen;45float H(vec2 p, float t){46  p.x += 0.42 * sin(p.y * 1.25 + t * 0.7);47  p.y += 0.3 * sin(p.x * 0.85 - t * 0.5);48  return sin(p.x * 1.7 + p.y * 0.55 + t) * 0.5549       + sin(p.x * 0.65 - p.y * 1.3 - t * 0.8) * 0.3350       + sin((p.x + p.y) * 2.5 + t * 1.25) * 0.12;51}52void main(){53  vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;54  float t = u_time * 0.35;55  mat2 rot = mat2(0.866, -0.5, 0.5, 0.866);56  vec2 p = rot * uv * (3.2 / u_fold);57  float e = 0.012;58  float h = H(p, t);59  vec2 grad = vec2(H(p + vec2(e, 0.0), t) - h, H(p + vec2(0.0, e), t) - h) / e;60  vec3 n = normalize(vec3(-grad * 0.42, 1.0));61  vec3 L = normalize(vec3(-0.55, 0.65, 0.75));62  vec3 Hv = normalize(L + vec3(0.0, 0.0, 1.0));63  float diff = clamp(dot(n, L), 0.0, 1.0);64  float nh = max(dot(n, Hv), 0.0);65  float spec = pow(nh, 48.0);66  float broad = pow(nh, 7.0);67  vec3 base = mix(u_c1, u_c2, smoothstep(-0.9, 0.9, h * 0.8 + uv.x * 0.7));68  float shade = smoothstep(-1.1, 0.6, h) * 0.75 + 0.25;69  vec3 col = u_bg + base * diff * diff * 1.15 * shade;70  col += u_hl * (spec * 0.85 + broad * 0.18 * shade) * u_sheen;71  col += u_hl * pow(1.0 - n.z, 2.0) * 0.08 * u_sheen;72  col *= 1.0 - 0.32 * dot(uv * vec2(0.8, 1.0), uv * vec2(0.8, 1.0));73  gl_FragColor = vec4(col, 1.0);74}`;75 76const VERT = "attribute vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}";77 78function hexRgb(hex: string, fb: RGB): RGB {79  const m = hex.trim().replace(/^#/, "");80  const full = m.length === 3 ? m.split("").map((ch) => ch + ch).join("") : m;81  if (!/^[0-9a-fA-F]{6}$/.test(full)) return fb;82  const n = parseInt(full, 16);83  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];84}85 86const rgba = (c: RGB, a: number) => `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${a})`;87 88function useReducedMotionPref() {89  const [reduced, setReduced] = React.useState(false);90  React.useEffect(() => {91    if (typeof window === "undefined" || !window.matchMedia) return;92    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");93    const update = () => setReduced(mq.matches);94    update();95    mq.addEventListener("change", update);96    return () => mq.removeEventListener("change", update);97  }, []);98  return reduced;99}100 101function useInView(ref: React.RefObject<HTMLElement | null>) {102  const [inView, setInView] = React.useState(true);103  React.useEffect(() => {104    const el = ref.current;105    if (!el || typeof IntersectionObserver === "undefined") return;106    const io = new IntersectionObserver((entries) => {107      const entry = entries[entries.length - 1];108      if (entry) setInView(entry.isIntersecting);109    }, { rootMargin: "80px" });110    io.observe(el);111    return () => io.disconnect();112  }, [ref]);113  return inView;114}115 116function compile(gl: WebGLRenderingContext, type: number, src: string) {117  const sh = gl.createShader(type);118  if (!sh) return null;119  gl.shaderSource(sh, src);120  gl.compileShader(sh);121  if (gl.getShaderParameter(sh, gl.COMPILE_STATUS)) return sh;122  gl.deleteShader(sh);123  return null;124}125 126/** Full-screen-triangle WebGL renderer; returns true when the CSS fallback should be shown instead */127function useShader(128  rootRef: React.RefObject<HTMLDivElement | null>,129  canvasRef: React.RefObject<HTMLCanvasElement | null>,130  frag: string,131  uniforms: () => UniformMap,132  running: boolean,133  speed: number,134  paramsKey: string,135  maxDpr = 2,136) {137  const [fallback, setFallback] = React.useState(false);138  const timeRef = React.useRef(12);139  const drawRef = React.useRef<(() => void) | null>(null);140  const uniRef = React.useRef(uniforms);141  uniRef.current = uniforms;142  const speedRef = React.useRef(speed);143  speedRef.current = speed;144 145  React.useEffect(() => {146    const root = rootRef.current;147    const canvas = canvasRef.current;148    if (!root || !canvas || fallback) return;149    const gl = canvas.getContext("webgl", { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: "low-power" });150    if (!gl) { setFallback(true); return; }151    const vs = compile(gl, gl.VERTEX_SHADER, VERT);152    const fs = compile(gl, gl.FRAGMENT_SHADER, frag);153    const prog = gl.createProgram();154    const buf = gl.createBuffer();155    const dispose = () => {156      gl.deleteBuffer(buf);157      gl.deleteProgram(prog);158      gl.deleteShader(vs);159      gl.deleteShader(fs);160    };161    if (!vs || !fs || !prog || !buf) { dispose(); setFallback(true); return; }162    gl.attachShader(prog, vs);163    gl.attachShader(prog, fs);164    gl.linkProgram(prog);165    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { dispose(); setFallback(true); return; }166    gl.useProgram(prog);167    gl.bindBuffer(gl.ARRAY_BUFFER, buf);168    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);169    const aPos = gl.getAttribLocation(prog, "a_pos");170    gl.enableVertexAttribArray(aPos);171    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);172    const locs = new Map<string, WebGLUniformLocation | null>();173    const loc = (n: string) => {174      if (!locs.has(n)) locs.set(n, gl.getUniformLocation(prog, n));175      return locs.get(n) ?? null;176    };177    const resize = () => {178      const rect = root.getBoundingClientRect();179      const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);180      canvas.width = Math.max(1, Math.round(rect.width * dpr));181      canvas.height = Math.max(1, Math.round(rect.height * dpr));182      gl.viewport(0, 0, canvas.width, canvas.height);183    };184    const draw = () => {185      if (gl.isContextLost()) return;186      gl.uniform2f(loc("u_res"), canvas.width, canvas.height);187      gl.uniform1f(loc("u_time"), timeRef.current);188      const u = uniRef.current();189      for (const k in u) {190        const v = u[k];191        if (typeof v === "number") gl.uniform1f(loc(k), v);192        else if (v.length === 2) gl.uniform2f(loc(k), v[0], v[1]);193        else gl.uniform3f(loc(k), v[0], v[1], v[2]);194      }195      gl.drawArrays(gl.TRIANGLES, 0, 3);196    };197    resize();198    draw();199    drawRef.current = draw;200    const ro = new ResizeObserver(() => { resize(); draw(); });201    ro.observe(root);202    const onLost = (e: Event) => { e.preventDefault(); drawRef.current = null; setFallback(true); };203    canvas.addEventListener("webglcontextlost", onLost);204    return () => {205      ro.disconnect();206      canvas.removeEventListener("webglcontextlost", onLost);207      drawRef.current = null;208      if (!gl.isContextLost()) dispose();209    };210  }, [frag, fallback, maxDpr, rootRef, canvasRef]);211 212  React.useEffect(() => {213    if (!running || fallback) return;214    let raf = 0;215    let last = performance.now();216    const tick = (now: number) => {217      const dt = Math.min(0.05, (now - last) / 1000);218      last = now;219      timeRef.current += dt * speedRef.current;220      drawRef.current?.();221      raf = requestAnimationFrame(tick);222    };223    raf = requestAnimationFrame(tick);224    return () => cancelAnimationFrame(raf);225  }, [running, fallback]);226 227  React.useEffect(() => { drawRef.current?.(); }, [paramsKey]);228 229  return fallback;230}231 232export function SilkGradient({233  color1 = "#6d28d9",234  color2 = "#1e40af",235  highlightColor = "#f5d0fe",236  backgroundColor = "#030306",237  speed = 1,238  foldScale = 1,239  sheen = 1,240  height = 380,241  borderRadius = 20,242  children,243  className = "",244  style,245}: SilkGradientProps) {246  const rootRef = React.useRef<HTMLDivElement>(null);247  const canvasRef = React.useRef<HTMLCanvasElement>(null);248  const reduced = useReducedMotionPref();249  const inView = useInView(rootRef);250  const safeSpeed = Math.max(0, speed);251  const safeFold = Math.max(0.3, Math.min(3, foldScale));252  const safeSheen = Math.max(0, Math.min(2, sheen));253  const c1 = hexRgb(color1, [0.43, 0.16, 0.85]);254  const c2 = hexRgb(color2, [0.12, 0.25, 0.69]);255  const hl = hexRgb(highlightColor, [0.96, 0.82, 1]);256  const bg = hexRgb(backgroundColor, [0.012, 0.012, 0.024]);257 258  const fallback = useShader(259    rootRef,260    canvasRef,261    FRAG,262    () => ({ u_c1: c1, u_c2: c2, u_hl: hl, u_bg: bg, u_fold: safeFold, u_sheen: safeSheen }),263    !reduced && inView && safeSpeed > 0,264    safeSpeed,265    [color1, color2, highlightColor, backgroundColor, safeFold, safeSheen].join("|"),266    1.5,267  );268 269  const band = (40 * safeFold).toFixed(0);270  const styleVars = {271    "--mf-sk-bg": backgroundColor,272    "--mf-sk-folds": `repeating-linear-gradient(115deg, ${rgba(bg, 1)} 0px, ${rgba(c1, 0.85)} ${(+band * 0.45).toFixed(0)}px, ${rgba(hl, 0.35 * safeSheen)} ${(+band * 0.55).toFixed(0)}px, ${rgba(c2, 0.8)} ${(+band * 0.7).toFixed(0)}px, ${rgba(bg, 1)} ${band}px)`,273    "--mf-sk-dur": `${(18 / Math.max(0.1, safeSpeed)).toFixed(1)}s`,274    height: `${height}px`,275    borderRadius: `${borderRadius}px`,276    ...style,277  } as React.CSSProperties;278 279  return (280    <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>281      {fallback ? (282        <div aria-hidden="true" className={`${styles.fallback} ${safeSpeed === 0 ? styles.paused : ""}`.trim()}>283          <div className={styles.folds} />284          <div className={styles.shade} />285        </div>286      ) : (287        <canvas ref={canvasRef} aria-hidden="true" className={styles.canvas} />288      )}289      <div className={styles.content}>290        {children ?? (291          <div className={styles.copy}>292            <h3 className={styles.title}>Silk Gradient</h3>293            <p className={styles.subtitle}>Dark satin folds catching a slow specular sheen.</p>294          </div>295        )}296      </div>297    </div>298  );299}