← All WebGL effects

Silk Sheen

Brightness taken from thread direction rather than position, so the highlight moves because the cloth turned.

ambientpremiumcalmelegant1 draw call · full-screen shader · light · 1 context · automatic · looping
queued1 draw call · 1 context
Variant

The scene in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

301 lines · react + three
"use client";

import * as React from "react";
import * as THREE from "three";

/**
 * Silk Sheen — the highlight is perpendicular to the thread.
 *
 * What makes silk look like silk is not its colour and not a gradient.
 * It is that the fabric is directional: light scatters in a band across
 * the fibre and hardly at all along it, so a sheet of silk is bright
 * wherever its threads happen to run across your line of sight and dull
 * wherever they run towards it. Fold the cloth and the highlight moves
 * because the direction moved, not because the shading did.
 *
 * So this shader carries a direction field rather than a brightness
 * field. The direction is the gradient of a slow scalar function turned
 * ninety degrees — a curl, which is divergence-free and therefore
 * cannot pool or drain, which is why the weave flows instead of
 * throbbing. Brightness is then one line: how square the thread lies to
 * the light. Every band, every soft edge and every fold in the result is
 * the direction field's doing, and nothing anywhere paints a stripe.
 *
 * Single file, react and three only. No textures, no add-ons.
 */

type Variant = "subtle" | "default" | "playful";

const VARIANTS = {
  subtle: { driftRate: 0.05, strandCount: 3, sheenOpacity: 0.3, weaveScale: 0.85 },
  default: { driftRate: 0.12, strandCount: 5, sheenOpacity: 0.5, weaveScale: 1.2 },
  playful: { driftRate: 0.28, strandCount: 9, sheenOpacity: 0.72, weaveScale: 1.75 },
} as const;

const PALETTES = {
  night: {
    base: [0.055, 0.06, 0.105],
    warp: [0.35, 0.3, 0.72],
    weft: [0.16, 0.55, 0.66],
    css: "linear-gradient(140deg, #171833 0%, #0e1024 55%, #0b0c18 100%)",
  },
  day: {
    base: [0.93, 0.925, 0.95],
    warp: [0.42, 0.36, 0.74],
    weft: [0.2, 0.55, 0.62],
    css: "linear-gradient(140deg, #eceaf5 0%, #f3f2f8 55%, #f6f6fa 100%)",
  },
} as const;

const VERTEX = /* glsl */ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = vec4(position.xy, 0.0, 1.0);
  }
`;

const FRAGMENT = /* glsl */ `
  varying vec2 vUv;

  uniform vec2  uResolution;
  uniform float uTime;
  uniform float uStrands;
  uniform float uOpacity;
  uniform float uWeave;
  uniform float uInk;
  uniform vec3  uBase;
  uniform vec3  uWarp;
  uniform vec3  uWeft;

  /** A slow scalar field. Its gradient is what the cloth is made of. */
  float potential(vec2 p, float t, float strands) {
    return sin(p.x * strands + t * 0.7) * 0.55
         + sin(p.y * strands * 0.78 - t * 0.5) * 0.45
         + sin((p.x + p.y) * strands * 0.46 + t * 0.31) * 0.35;
  }

  /**
   * The thread direction: the gradient of that field, rotated a quarter
   * turn. Rotating it is what makes this a curl — a field with no
   * sources and no sinks, so the weave can never pool in one place and
   * drain from another, which is what separates flowing cloth from a
   * throbbing gradient.
   */
  vec2 thread(vec2 p, float t, float strands) {
    float e = 0.0016;
    float gx = potential(p + vec2(e, 0.0), t, strands) - potential(p - vec2(e, 0.0), t, strands);
    float gy = potential(p + vec2(0.0, e), t, strands) - potential(p - vec2(0.0, e), t, strands);
    return normalize(vec2(-gy, gx) + 1e-6);
  }

  float hash(vec2 p) {
    return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453);
  }

  void main() {
    float aspect = uResolution.x / max(uResolution.y, 1.0);
    vec2 p = vec2(vUv.x * aspect, vUv.y) * uWeave;

    vec2 dir = thread(p, uTime, uStrands);

    // Light arriving across the frame, and the eye looking straight in.
    vec2 toLight = normalize(vec2(0.72, 0.69));

    // The one line that makes it silk: brightness is how squarely the
    // thread lies across the light, not how far along the frame we are.
    float across = 1.0 - abs(dot(dir, toLight));
    float sheen = pow(clamp(across, 0.0, 1.0), 3.4);

    // A second, tighter lobe gives the specular edge a real fabric has
    // where a fold turns over. Same direction field, sharper exponent.
    float glint = pow(clamp(across, 0.0, 1.0), 22.0);

    // Which way the thread runs also decides which of the two dye
    // colours shows, the way shot silk changes colour with the angle.
    float shot = clamp(dir.x * 0.5 + 0.5, 0.0, 1.0);
    vec3 dye = mix(uWeft, uWarp, shot);

    float amount = (sheen * 0.62 + glint * 0.5) * uOpacity;

    vec3 lit = uBase + dye * amount;
    vec3 inked = mix(uBase, dye, clamp(amount * 1.25, 0.0, 1.0));
    vec3 color = mix(lit, inked, uInk);

    color += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
  }
`;

/** Relative luminance of a computed rgb() colour, 0–1. */
function luminanceOf(color: string): number {
  const parts = color.match(/[\d.]+/g);
  if (!parts || parts.length < 3) return 0.9;
  const [r, g, b] = parts.slice(0, 3).map((value) => Number(value) / 255);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

export type SilkSheenProps = {
  variant?: Variant;
  height?: string;
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;
};

export function SilkSheen({
  variant = "default",
  height = "100%",
  className,
  style,
  children,
}: SilkSheenProps) {
  const hostRef = React.useRef<HTMLDivElement | null>(null);
  const cfg = VARIANTS[variant] ?? VARIANTS.default;

  React.useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    const palette =
      luminanceOf(getComputedStyle(host).color) > 0.5 ? PALETTES.night : PALETTES.day;
    host.style.background = palette.css;

    const reduced =
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let renderer: THREE.WebGLRenderer;
    try {
      renderer = new THREE.WebGLRenderer({
        antialias: false,
        alpha: false,
        powerPreference: "low-power",
      });
    } catch {
      return; // No WebGL. The CSS still above is already painted.
    }

    const canvas = renderer.domElement;
    canvas.style.cssText =
      "position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    host.insertBefore(canvas, host.firstChild);

    const uniforms = {
      uResolution: { value: new THREE.Vector2(1, 1) },
      uTime: { value: 0 },
      uStrands: { value: 5 },
      uOpacity: { value: 0.5 },
      uWeave: { value: 1.2 },
      uInk: { value: palette === PALETTES.day ? 1 : 0 },
      uBase: { value: new THREE.Vector3(...palette.base) },
      uWarp: { value: new THREE.Vector3(...palette.warp) },
      uWeft: { value: new THREE.Vector3(...palette.weft) },
    };
    uniforms.uStrands.value = cfg.strandCount;
    uniforms.uOpacity.value = cfg.sheenOpacity;
    uniforms.uWeave.value = cfg.weaveScale;

    const geometry = new THREE.PlaneGeometry(2, 2);
    const material = new THREE.ShaderMaterial({
      vertexShader: VERTEX,
      fragmentShader: FRAGMENT,
      uniforms,
      depthTest: false,
      depthWrite: false,
    });
    const scene = new THREE.Scene();
    scene.add(new THREE.Mesh(geometry, material));
    const camera = new THREE.Camera();

    const resize = () => {
      const width = Math.max(1, host.clientWidth);
      const heightPx = Math.max(1, host.clientHeight);
      renderer.setSize(width, heightPx, false);
      uniforms.uResolution.value.set(width, heightPx);
    };
    resize();

    const observer = new ResizeObserver(resize);
    observer.observe(host);

    let frame = 0;
    let phase = 0;
    let last = 0;
    let running = false;

    const draw = () => renderer.render(scene, camera);

    const tick = (now: number) => {
      const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      phase += delta * cfg.driftRate * 6.0;
      uniforms.uTime.value = phase;
      draw();
      frame = requestAnimationFrame(tick);
    };

    const start = () => {
      if (running) return;
      running = true;
      last = 0;
      frame = requestAnimationFrame(tick);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(frame);
    };

    const onLost = (event: Event) => {
      event.preventDefault();
      stop();
    };
    const onRestored = () => {
      resize();
      start();
    };
    canvas.addEventListener("webglcontextlost", onLost);
    canvas.addEventListener("webglcontextrestored", onRestored);

    if (reduced) {
      // Cloth at rest is still cloth: one frame keeps every fold and
      // every highlight, and only the drift is dropped.
      uniforms.uTime.value = 1.1;
      draw();
    } else {
      start();
    }

    return () => {
      stop();
      observer.disconnect();
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      geometry.dispose();
      material.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
      canvas.remove();
    };
  }, [cfg]);

  return (
    <div
      ref={hostRef}
      className={className}
      style={{
        position: "relative",
        width: "100%",
        height,
        overflow: "hidden",
        background: PALETTES.night.css,
        ...style,
      }}
    >
      {children}
    </div>
  );
}

export default SilkSheen;

About this effect

A backdrop for the one surface a product wants to feel expensive — a pricing panel, a paywall, an upgrade sheet. What makes silk read as silk is that it is directional: light scatters in a band across the fibre and barely at all along it, so the cloth is bright wherever its threads lie across your line of sight. Fold it and the highlight moves because the direction moved, not because someone shaded it. So the field this shader carries is a direction, not a brightness. The direction is the gradient of a slow scalar function turned ninety degrees — a curl, which has no sources and no sinks, and that is the reason the weave flows rather than throbs: a field that could pool would pulse in place. Brightness after that is a single line of arithmetic, how squarely the thread lies to the light, and every band and soft fold in the picture is that one line answering the direction field. Nothing paints a stripe.

Pricing panelPaywall backdropUpgrade sheetLanding page hero

Related effects