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