Prism
← All primitives

ShaderField

Shader

Full-screen domain-warped GLSL noise refracted through a color spectrum. Pointer-reactive, resolution-independent, zero geometry.

$npx shadcn@latest add https://prism.icglabs.co/r/shader-field.json
Dependencies:three@react-three/fiber
View raw manifest →

components/prism/ShaderField.tsx
"use client";

import { useMemo, useRef } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { useDeviceTier } from "@/lib/useDeviceTier";
import { AutoDpr, PauseWhenOffscreen } from "@/components/showcase/CanvasPerf";

/**
 * ShaderField — a full-screen fragment shader: domain-warped fractal noise
 * refracted through a configurable color spectrum. Flows on its own, drifts
 * toward the pointer. Zero geometry beyond a clip-space quad, so it's cheap and
 * resolution-independent. Custom GLSL — the kind of "signature surface" that
 * defines award-winning sites.
 */
export type ShaderFieldProps = {
  /** Exactly 5 hex stops sampled across the noise field. */
  palette?: [string, string, string, string, string];
  speed?: number;
  /** Higher = more turbulent warping. */
  warp?: number;
  className?: string;
};

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

const FRAG = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform float uWarp;
uniform vec2 uMouse;
uniform float uAspect;
uniform vec3 uPalette[5];

// hash / value-noise / fbm
float hash(vec2 p) {
  p = fract(p * vec2(123.34, 345.45));
  p += dot(p, p + 34.345);
  return fract(p.x * p.y);
}
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  float a = hash(i);
  float b = hash(i + vec2(1.0, 0.0));
  float c = hash(i + vec2(0.0, 1.0));
  float d = hash(i + vec2(1.0, 1.0));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 4; i++) {
    v += a * noise(p);
    p *= 2.0;
    a *= 0.5;
  }
  return v;
}

vec3 prism(float t) {
  vec3 c = mix(uPalette[0], uPalette[1], smoothstep(0.0, 0.25, t));
  c = mix(c, uPalette[2], smoothstep(0.25, 0.5, t));
  c = mix(c, uPalette[3], smoothstep(0.5, 0.75, t));
  c = mix(c, uPalette[4], smoothstep(0.75, 1.0, t));
  return c;
}

void main() {
  vec2 uv = vUv;
  uv.x *= uAspect;
  float t = uTime * 0.08;

  // pointer pulls the field
  vec2 m = (uMouse - 0.5) * 0.6;
  uv += m;

  // single-pass domain warp (Inigo Quilez style) — cheap but rich
  vec2 q = vec2(fbm(uv + t), fbm(uv + vec2(5.2, 1.3) - t));
  float f = fbm(uv + uWarp * q + 0.12 * t);

  float shade = clamp(f * f * 2.4 + 0.15, 0.0, 1.0);
  vec3 col = prism(clamp(f + 0.25 * q.x, 0.0, 1.0));
  col *= 0.35 + 0.9 * shade;

  // bright refraction filaments where the warp folds
  float fil = smoothstep(0.78, 0.92, f) * 0.6;
  col += fil * mix(uPalette[2], uPalette[3], q.y);

  // vignette toward obsidian
  vec2 vc = vUv - 0.5;
  float vig = smoothstep(0.95, 0.25, length(vc));
  col *= mix(0.25, 1.0, vig);

  // subtle dithered grain to kill banding
  float g = hash(vUv * 850.0 + t) * 0.025 - 0.0125;
  col += g;

  gl_FragColor = vec4(col, 1.0);
}
`;

function paletteToFloats(palette: string[]): Float32Array {
  const arr = new Float32Array(15);
  for (let i = 0; i < 5; i++) {
    const c = new THREE.Color(palette[Math.min(i, palette.length - 1)]);
    arr[i * 3] = c.r;
    arr[i * 3 + 1] = c.g;
    arr[i * 3 + 2] = c.b;
  }
  return arr;
}

function FieldMesh({
  palette,
  speed,
  warp,
}: {
  palette: string[];
  speed: number;
  warp: number;
}) {
  const mat = useRef<THREE.ShaderMaterial>(null);
  const mouse = useRef(new THREE.Vector2(0.5, 0.5));
  const size = useThree((s) => s.size);
  const reduced =
    typeof window !== "undefined" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  const uniforms = useMemo(
    () => ({
      uTime: { value: 0 },
      uWarp: { value: warp },
      uMouse: { value: new THREE.Vector2(0.5, 0.5) },
      uAspect: { value: 1 },
      uPalette: { value: paletteToFloats(palette) },
    }),
    [palette, warp]
  );

  useFrame((state, delta) => {
    const m = mat.current;
    if (!m) return;
    if (!reduced) m.uniforms.uTime.value += delta * speed;
    m.uniforms.uAspect.value = size.width / size.height;
    // ease toward pointer (-1..1 → 0..1)
    const target = mouse.current;
    target.set((state.pointer.x + 1) / 2, (state.pointer.y + 1) / 2);
    m.uniforms.uMouse.value.lerp(target, 0.04);
  });

  return (
    <mesh>
      <planeGeometry args={[2, 2]} />
      <shaderMaterial ref={mat} vertexShader={VERT} fragmentShader={FRAG} uniforms={uniforms} />
    </mesh>
  );
}

export function ShaderField({
  palette = ["#0a0a14", "#5b7cff", "#1fd4e6", "#8b5cff", "#ff3d81"],
  speed = 1,
  warp = 4.0,
  className,
}: ShaderFieldProps) {
  const q = useDeviceTier();
  return (
    <div className={className} style={{ width: "100%", height: "100%" }}>
      {/* dpr is a range; AdaptiveDpr scales it by measured FPS and the loop pauses
          off-screen — a full-bleed fragment shader stays cheap even on weak GPUs. */}
      <Canvas dpr={q.dpr} gl={{ antialias: false, powerPreference: "high-performance" }}>
        <FieldMesh palette={palette} speed={speed} warp={warp} />
        <AutoDpr />
        <PauseWhenOffscreen />
      </Canvas>
    </div>
  );
}
Live demo — read-only. Every section is a real, copyable primitive.