Перейти к содержимому

Текстуры

Анимированный Mesh Gradient

Анимированный WebGL-градиент с настраиваемыми цветами, шумом, мягкостью и производительностью.


Анимированный Mesh Gradient

AnimatedMeshGradient создаёт живой многоцветный фон с плавно движущимися цветовыми областями. Градиент отрисовывается через WebGL, а CSS-фолбэк остаётся видимым, если WebGL недоступен.

Превью

WebGL texture

Цвет в движении

Плавный градиент для hero-секций и карточек

<script lang="ts" module>
  import type { Snippet } from "svelte";

  // Значения из ./const в сообщении не пришли, ниже разумные дефолты для веба.
  export const DEFAULT_INITIAL_COLORS: IMeshGradientColor[] = [
    "#7c3aed",
    "#06b6d4",
    "#f472b6",
    "#fbbf24",
  ];
  export const DEFAULT_PERFORMANCE = { undersampling: 0.5, fpsLock: 60 };

  /** CSS-цвет или {r,g,b}: 0..1 либо 0..255 */
  export type IMeshGradientColor = string | { r: number; g: number; b: number };

  export type IAnimatedMeshGradient = {
    /** До 4 цветов; если меньше, добираются из дефолтных */
    colors?: IMeshGradientColor[];
    speed?: number;
    /** 0..1 */
    noise?: number;
    /** 0..1 */
    blur?: number;
    /** 0..2 */
    contrast?: number;
    animated?: boolean;
    /** Число (px) или CSS-значение. По умолчанию 100% */
    width?: number | string;
    height?: number | string;
    performance?: {
      /** Масштаб разрешения канваса: 0.1..2 (меньше = быстрее) */
      undersampling?: number;
      /** Максимальный FPS */
      fpsLock?: number;
    };
    /** Inline-стили корневого элемента */
    style?: string;
    class?: string;
    children?: Snippet;
  };

  // Собственный GLSL-шейдер: SkSL из ./conf в WebGL напрямую не переносится.
  const VERTEX = `
    attribute vec2 a_pos;
    void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
  `;

  const FRAGMENT = `
    #ifdef GL_FRAGMENT_PRECISION_HIGH
    precision highp float;
    #else
    precision mediump float;
    #endif

    uniform vec2 u_resolution;
    uniform float u_time;
    uniform float u_noise;
    uniform float u_blur;
    uniform float u_contrast;
    uniform vec3 u_c1;
    uniform vec3 u_c2;
    uniform vec3 u_c3;
    uniform vec3 u_c4;

    float hash(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * 0.1031);
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.x + p3.y) * p3.z);
    }

    vec2 orbit(float t, vec2 c, vec2 r, vec2 ph, vec2 f) {
      return c + r * vec2(sin(t * f.x + ph.x), cos(t * f.y + ph.y));
    }

    void main() {
      vec2 uv = gl_FragCoord.xy / u_resolution;
      vec2 s = vec2(u_resolution.x / u_resolution.y, 1.0);
      float t = u_time * 0.5;

      // лёгкая волновая деформация для «живой» формы
      vec2 q = uv + 0.06 * vec2(sin(uv.y * 4.0 + t * 1.3), cos(uv.x * 4.0 - t * 1.1));

      vec2 p1 = orbit(t, vec2(0.25, 0.30), vec2(0.20, 0.20), vec2(0.0, 1.0), vec2(1.0, 0.8));
      vec2 p2 = orbit(t, vec2(0.75, 0.30), vec2(0.20, 0.25), vec2(2.0, 0.5), vec2(0.7, 1.1));
      vec2 p3 = orbit(t, vec2(0.30, 0.75), vec2(0.25, 0.20), vec2(4.0, 2.0), vec2(0.9, 0.6));
      vec2 p4 = orbit(t, vec2(0.75, 0.75), vec2(0.20, 0.20), vec2(1.0, 3.0), vec2(0.6, 1.0));

      // blur управляет мягкостью перехода: меньше экспонента = плавнее
      float e = mix(6.0, 1.5, u_blur);
      float w1 = 1.0 / pow(length((q - p1) * s) + 0.02, e);
      float w2 = 1.0 / pow(length((q - p2) * s) + 0.02, e);
      float w3 = 1.0 / pow(length((q - p3) * s) + 0.02, e);
      float w4 = 1.0 / pow(length((q - p4) * s) + 0.02, e);

      vec3 col = (w1 * u_c1 + w2 * u_c2 + w3 * u_c3 + w4 * u_c4) / (w1 + w2 + w3 + w4);

      col = (col - 0.5) * u_contrast + 0.5;

      float n = hash(gl_FragCoord.xy + fract(u_time) * 100.0) - 0.5;
      col += n * u_noise * 0.25;

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

  function compile(gl: WebGLRenderingContext, type: number, src: string) {
    const sh = gl.createShader(type);
    if (!sh) return null;
    gl.shaderSource(sh, src);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      console.warn("[AnimatedMeshGradient] shader error:", gl.getShaderInfoLog(sh));
      gl.deleteShader(sh);
      return null;
    }
    return sh;
  }

  let colorCtx: CanvasRenderingContext2D | null = null;

  /** Любой цвет → [r,g,b] в диапазоне 0..1 (только в браузере) */
  function toRgb(c: IMeshGradientColor): [number, number, number] {
    if (typeof c !== "string") {
      const max = Math.max(c.r, c.g, c.b);
      const k = max > 1 ? 255 : 1;
      return [c.r / k, c.g / k, c.b / k];
    }
    colorCtx ??= document
      .createElement("canvas")
      .getContext("2d", { willReadFrequently: true });
    if (!colorCtx) return [0, 0, 0];
    colorCtx.clearRect(0, 0, 1, 1);
    colorCtx.fillStyle = "#000";
    colorCtx.fillStyle = c;
    colorCtx.fillRect(0, 0, 1, 1);
    const [r, g, b] = colorCtx.getImageData(0, 0, 1, 1).data;
    return [r / 255, g / 255, b / 255];
  }

  function toCss(c: IMeshGradientColor): string {
    if (typeof c === "string") return c;
    const k = Math.max(c.r, c.g, c.b) > 1 ? 1 : 255;
    return `rgb(${Math.round(c.r * k)}, ${Math.round(c.g * k)}, ${Math.round(c.b * k)})`;
  }
</script>

<script lang="ts">
  let {
    colors = DEFAULT_INITIAL_COLORS,
    speed = 1,
    noise = 0.15,
    blur = 0.4,
    contrast = 1,
    animated = true,
    width = "100%",
    height = "100%",
    performance: perf = {},
    style = "",
    class: className = "",
    children,
  }: IAnimatedMeshGradient = $props();

  let container: HTMLDivElement | undefined = $state();
  let canvas: HTMLCanvasElement | undefined = $state();

  const scale = $derived(
    Math.min(2, Math.max(0.1, perf?.undersampling ?? DEFAULT_PERFORMANCE.undersampling)),
  );
  const fpsLock = $derived(perf?.fpsLock ?? DEFAULT_PERFORMANCE.fpsLock);

  // Ровно 4 цвета
  const safeColors = $derived.by<IMeshGradientColor[]>(() => {
    const result = [...colors];
    while (result.length < 4) {
      result.push(DEFAULT_INITIAL_COLORS[result.length % DEFAULT_INITIAL_COLORS.length]);
    }
    return result.slice(0, 4);
  });

  // Вычисляется только в браузере (читается из draw/эффектов)
  const rgbColors = $derived(safeColors.map(toRgb));

  const fallbackBg = $derived(
    `linear-gradient(135deg, ${safeColors.map(toCss).join(", ")})`,
  );

  const toCssSize = (v: number | string) => (typeof v === "number" ? `${v}px` : v);

  // --- WebGL (не реактивное состояние) ---
  let gl: WebGLRenderingContext | null = null;
  let u: Record<string, WebGLUniformLocation | null> = {};
  let time = 0;

  function draw() {
    if (!gl || !canvas) return;
    const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
    const [c1, c2, c3, c4] = rgbColors;

    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.uniform2f(u.resolution, canvas.width, canvas.height);
    gl.uniform1f(u.time, time);
    gl.uniform1f(u.noise, clamp(noise, 0, 1));
    gl.uniform1f(u.blur, clamp(blur, 0, 1));
    gl.uniform1f(u.contrast, clamp(contrast, 0, 2));
    gl.uniform3f(u.c1, c1[0], c1[1], c1[2]);
    gl.uniform3f(u.c2, c2[0], c2[1], c2[2]);
    gl.uniform3f(u.c3, c3[0], c3[1], c3[2]);
    gl.uniform3f(u.c4, c4[0], c4[1], c4[2]);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }

  // 1) Инициализация WebGL
  $effect(() => {
    const el = canvas;
    if (!el) return;

    const ctx = el.getContext("webgl", {
      antialias: false,
      alpha: false,
      powerPreference: "low-power",
    });
    if (!ctx) return; // остаётся CSS-фолбэк

    const vs = compile(ctx, ctx.VERTEX_SHADER, VERTEX);
    const fs = compile(ctx, ctx.FRAGMENT_SHADER, FRAGMENT);
    const program = ctx.createProgram();
    if (!vs || !fs || !program) return;

    ctx.attachShader(program, vs);
    ctx.attachShader(program, fs);
    ctx.linkProgram(program);
    if (!ctx.getProgramParameter(program, ctx.LINK_STATUS)) {
      console.warn("[AnimatedMeshGradient] link error:", ctx.getProgramInfoLog(program));
      return;
    }
    ctx.useProgram(program);

    // Полноэкранный треугольник
    const buf = ctx.createBuffer();
    ctx.bindBuffer(ctx.ARRAY_BUFFER, buf);
    ctx.bufferData(
      ctx.ARRAY_BUFFER,
      new Float32Array([-1, -1, 3, -1, -1, 3]),
      ctx.STATIC_DRAW,
    );
    const loc = ctx.getAttribLocation(program, "a_pos");
    ctx.enableVertexAttribArray(loc);
    ctx.vertexAttribPointer(loc, 2, ctx.FLOAT, false, 0, 0);

    const get = (n: string) => ctx.getUniformLocation(program, n);
    u = {
      resolution: get("u_resolution"),
      time: get("u_time"),
      noise: get("u_noise"),
      blur: get("u_blur"),
      contrast: get("u_contrast"),
      c1: get("u_c1"),
      c2: get("u_c2"),
      c3: get("u_c3"),
      c4: get("u_c4"),
    };
    gl = ctx;
    draw();

    return () => {
      gl = null;
      ctx.deleteBuffer(buf);
      ctx.deleteProgram(program);
      ctx.deleteShader(vs);
      ctx.deleteShader(fs);
      ctx.getExtension("WEBGL_lose_context")?.loseContext();
    };
  });

  // 2) Размер канваса = размер контейнера × undersampling (аналог width*scale в оригинале)
  $effect(() => {
    const el = container;
    const cv = canvas;
    const k = scale;
    if (!el || !cv) return;

    const sync = (w: number, h: number) => {
      const cw = Math.max(1, Math.round(w * k));
      const ch = Math.max(1, Math.round(h * k));
      if (cv.width !== cw || cv.height !== ch) {
        cv.width = cw;
        cv.height = ch;
      }
      draw();
    };

    const ro = new ResizeObserver(([entry]) => {
      sync(entry.contentRect.width, entry.contentRect.height);
    });
    ro.observe(el);
    return () => ro.disconnect();
  });

  // 3) Цикл анимации с ограничением FPS (аналог useFrameTime)
  $effect(() => {
    const lock = Math.max(1, fpsLock);
    const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;

    if (!animated || reduced) {
      draw();
      return;
    }

    const minDelta = 1000 / lock;
    let raf = 0;
    let last: number | undefined;

    const tick = (now: number) => {
      raf = requestAnimationFrame(tick);
      last ??= now;
      const dt = now - last;
      if (dt < minDelta) return;
      last = now;
      time += (Math.min(dt, 100) / 1000) * speed;
      draw();
    };
    raf = requestAnimationFrame(tick);

    return () => cancelAnimationFrame(raf);
  });

  // 4) Перерисовка при смене параметров (важно, когда animated=false)
  $effect(() => {
    draw();
  });
</script>

<div
  bind:this={container}
  class="mesh-gradient {className}"
  style="width: {toCssSize(width)}; height: {toCssSize(height)}; {style}"
>
  <!-- CSS-фолбэк: виден до первого кадра и если WebGL недоступен -->
  <div class="fallback" style="background: {fallbackBg};" aria-hidden="true"></div>
  <canvas bind:this={canvas} class="canvas" aria-hidden="true"></canvas>

  <div class="content">
    {@render children?.()}
  </div>
</div>

<style>
  .mesh-gradient {
    position: relative;
    overflow: hidden;
  }

  .fallback,
  .canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
  }

  .content {
    position: relative;
    z-index: 1;
    width: 100%;
    height: 100%;
  }
</style>
<script lang="ts" module>
  import type { Snippet } from "svelte";

  // Значения из ./const в сообщении не пришли, ниже разумные дефолты для веба.
  export const DEFAULT_INITIAL_COLORS: IMeshGradientColor[] = [
    "#7c3aed",
    "#06b6d4",
    "#f472b6",
    "#fbbf24",
  ];
  export const DEFAULT_PERFORMANCE = { undersampling: 0.5, fpsLock: 60 };

  /** CSS-цвет или {r,g,b}: 0..1 либо 0..255 */
  export type IMeshGradientColor = string | { r: number; g: number; b: number };

  export type IAnimatedMeshGradient = {
    /** До 4 цветов; если меньше, добираются из дефолтных */
    colors?: IMeshGradientColor[];
    speed?: number;
    /** 0..1 */
    noise?: number;
    /** 0..1 */
    blur?: number;
    /** 0..2 */
    contrast?: number;
    animated?: boolean;
    /** Число (px) или CSS-значение. По умолчанию 100% */
    width?: number | string;
    height?: number | string;
    performance?: {
      /** Масштаб разрешения канваса: 0.1..2 (меньше = быстрее) */
      undersampling?: number;
      /** Максимальный FPS */
      fpsLock?: number;
    };
    /** Inline-стили корневого элемента */
    style?: string;
    class?: string;
    children?: Snippet;
  };

  // Собственный GLSL-шейдер: SkSL из ./conf в WebGL напрямую не переносится.
  const VERTEX = `
    attribute vec2 a_pos;
    void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
  `;

  const FRAGMENT = `
    #ifdef GL_FRAGMENT_PRECISION_HIGH
    precision highp float;
    #else
    precision mediump float;
    #endif

    uniform vec2 u_resolution;
    uniform float u_time;
    uniform float u_noise;
    uniform float u_blur;
    uniform float u_contrast;
    uniform vec3 u_c1;
    uniform vec3 u_c2;
    uniform vec3 u_c3;
    uniform vec3 u_c4;

    float hash(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * 0.1031);
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.x + p3.y) * p3.z);
    }

    vec2 orbit(float t, vec2 c, vec2 r, vec2 ph, vec2 f) {
      return c + r * vec2(sin(t * f.x + ph.x), cos(t * f.y + ph.y));
    }

    void main() {
      vec2 uv = gl_FragCoord.xy / u_resolution;
      vec2 s = vec2(u_resolution.x / u_resolution.y, 1.0);
      float t = u_time * 0.5;

      // лёгкая волновая деформация для «живой» формы
      vec2 q = uv + 0.06 * vec2(sin(uv.y * 4.0 + t * 1.3), cos(uv.x * 4.0 - t * 1.1));

      vec2 p1 = orbit(t, vec2(0.25, 0.30), vec2(0.20, 0.20), vec2(0.0, 1.0), vec2(1.0, 0.8));
      vec2 p2 = orbit(t, vec2(0.75, 0.30), vec2(0.20, 0.25), vec2(2.0, 0.5), vec2(0.7, 1.1));
      vec2 p3 = orbit(t, vec2(0.30, 0.75), vec2(0.25, 0.20), vec2(4.0, 2.0), vec2(0.9, 0.6));
      vec2 p4 = orbit(t, vec2(0.75, 0.75), vec2(0.20, 0.20), vec2(1.0, 3.0), vec2(0.6, 1.0));

      // blur управляет мягкостью перехода: меньше экспонента = плавнее
      float e = mix(6.0, 1.5, u_blur);
      float w1 = 1.0 / pow(length((q - p1) * s) + 0.02, e);
      float w2 = 1.0 / pow(length((q - p2) * s) + 0.02, e);
      float w3 = 1.0 / pow(length((q - p3) * s) + 0.02, e);
      float w4 = 1.0 / pow(length((q - p4) * s) + 0.02, e);

      vec3 col = (w1 * u_c1 + w2 * u_c2 + w3 * u_c3 + w4 * u_c4) / (w1 + w2 + w3 + w4);

      col = (col - 0.5) * u_contrast + 0.5;

      float n = hash(gl_FragCoord.xy + fract(u_time) * 100.0) - 0.5;
      col += n * u_noise * 0.25;

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

  function compile(gl: WebGLRenderingContext, type: number, src: string) {
    const sh = gl.createShader(type);
    if (!sh) return null;
    gl.shaderSource(sh, src);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      console.warn("[AnimatedMeshGradient] shader error:", gl.getShaderInfoLog(sh));
      gl.deleteShader(sh);
      return null;
    }
    return sh;
  }

  let colorCtx: CanvasRenderingContext2D | null = null;

  /** Любой цвет → [r,g,b] в диапазоне 0..1 (только в браузере) */
  function toRgb(c: IMeshGradientColor): [number, number, number] {
    if (typeof c !== "string") {
      const max = Math.max(c.r, c.g, c.b);
      const k = max > 1 ? 255 : 1;
      return [c.r / k, c.g / k, c.b / k];
    }
    colorCtx ??= document
      .createElement("canvas")
      .getContext("2d", { willReadFrequently: true });
    if (!colorCtx) return [0, 0, 0];
    colorCtx.clearRect(0, 0, 1, 1);
    colorCtx.fillStyle = "#000";
    colorCtx.fillStyle = c;
    colorCtx.fillRect(0, 0, 1, 1);
    const [r, g, b] = colorCtx.getImageData(0, 0, 1, 1).data;
    return [r / 255, g / 255, b / 255];
  }

  function toCss(c: IMeshGradientColor): string {
    if (typeof c === "string") return c;
    const k = Math.max(c.r, c.g, c.b) > 1 ? 1 : 255;
    return `rgb(${Math.round(c.r * k)}, ${Math.round(c.g * k)}, ${Math.round(c.b * k)})`;
  }
</script>

<script lang="ts">
  let {
    colors = DEFAULT_INITIAL_COLORS,
    speed = 1,
    noise = 0.15,
    blur = 0.4,
    contrast = 1,
    animated = true,
    width = "100%",
    height = "100%",
    performance: perf = {},
    style = "",
    class: className = "",
    children,
  }: IAnimatedMeshGradient = $props();

  let container: HTMLDivElement | undefined = $state();
  let canvas: HTMLCanvasElement | undefined = $state();

  const scale = $derived(
    Math.min(2, Math.max(0.1, perf?.undersampling ?? DEFAULT_PERFORMANCE.undersampling)),
  );
  const fpsLock = $derived(perf?.fpsLock ?? DEFAULT_PERFORMANCE.fpsLock);

  // Ровно 4 цвета
  const safeColors = $derived.by<IMeshGradientColor[]>(() => {
    const result = [...colors];
    while (result.length < 4) {
      result.push(DEFAULT_INITIAL_COLORS[result.length % DEFAULT_INITIAL_COLORS.length]);
    }
    return result.slice(0, 4);
  });

  // Вычисляется только в браузере (читается из draw/эффектов)
  const rgbColors = $derived(safeColors.map(toRgb));

  const fallbackBg = $derived(
    `linear-gradient(135deg, ${safeColors.map(toCss).join(", ")})`,
  );

  const toCssSize = (v: number | string) => (typeof v === "number" ? `${v}px` : v);

  // --- WebGL (не реактивное состояние) ---
  let gl: WebGLRenderingContext | null = null;
  let u: Record<string, WebGLUniformLocation | null> = {};
  let time = 0;

  function draw() {
    if (!gl || !canvas) return;
    const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
    const [c1, c2, c3, c4] = rgbColors;

    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.uniform2f(u.resolution, canvas.width, canvas.height);
    gl.uniform1f(u.time, time);
    gl.uniform1f(u.noise, clamp(noise, 0, 1));
    gl.uniform1f(u.blur, clamp(blur, 0, 1));
    gl.uniform1f(u.contrast, clamp(contrast, 0, 2));
    gl.uniform3f(u.c1, c1[0], c1[1], c1[2]);
    gl.uniform3f(u.c2, c2[0], c2[1], c2[2]);
    gl.uniform3f(u.c3, c3[0], c3[1], c3[2]);
    gl.uniform3f(u.c4, c4[0], c4[1], c4[2]);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }

  // 1) Инициализация WebGL
  $effect(() => {
    const el = canvas;
    if (!el) return;

    const ctx = el.getContext("webgl", {
      antialias: false,
      alpha: false,
      powerPreference: "low-power",
    });
    if (!ctx) return; // остаётся CSS-фолбэк

    const vs = compile(ctx, ctx.VERTEX_SHADER, VERTEX);
    const fs = compile(ctx, ctx.FRAGMENT_SHADER, FRAGMENT);
    const program = ctx.createProgram();
    if (!vs || !fs || !program) return;

    ctx.attachShader(program, vs);
    ctx.attachShader(program, fs);
    ctx.linkProgram(program);
    if (!ctx.getProgramParameter(program, ctx.LINK_STATUS)) {
      console.warn("[AnimatedMeshGradient] link error:", ctx.getProgramInfoLog(program));
      return;
    }
    ctx.useProgram(program);

    // Полноэкранный треугольник
    const buf = ctx.createBuffer();
    ctx.bindBuffer(ctx.ARRAY_BUFFER, buf);
    ctx.bufferData(
      ctx.ARRAY_BUFFER,
      new Float32Array([-1, -1, 3, -1, -1, 3]),
      ctx.STATIC_DRAW,
    );
    const loc = ctx.getAttribLocation(program, "a_pos");
    ctx.enableVertexAttribArray(loc);
    ctx.vertexAttribPointer(loc, 2, ctx.FLOAT, false, 0, 0);

    const get = (n: string) => ctx.getUniformLocation(program, n);
    u = {
      resolution: get("u_resolution"),
      time: get("u_time"),
      noise: get("u_noise"),
      blur: get("u_blur"),
      contrast: get("u_contrast"),
      c1: get("u_c1"),
      c2: get("u_c2"),
      c3: get("u_c3"),
      c4: get("u_c4"),
    };
    gl = ctx;
    draw();

    return () => {
      gl = null;
      ctx.deleteBuffer(buf);
      ctx.deleteProgram(program);
      ctx.deleteShader(vs);
      ctx.deleteShader(fs);
      ctx.getExtension("WEBGL_lose_context")?.loseContext();
    };
  });

  // 2) Размер канваса = размер контейнера × undersampling (аналог width*scale в оригинале)
  $effect(() => {
    const el = container;
    const cv = canvas;
    const k = scale;
    if (!el || !cv) return;

    const sync = (w: number, h: number) => {
      const cw = Math.max(1, Math.round(w * k));
      const ch = Math.max(1, Math.round(h * k));
      if (cv.width !== cw || cv.height !== ch) {
        cv.width = cw;
        cv.height = ch;
      }
      draw();
    };

    const ro = new ResizeObserver(([entry]) => {
      sync(entry.contentRect.width, entry.contentRect.height);
    });
    ro.observe(el);
    return () => ro.disconnect();
  });

  // 3) Цикл анимации с ограничением FPS (аналог useFrameTime)
  $effect(() => {
    const lock = Math.max(1, fpsLock);
    const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;

    if (!animated || reduced) {
      draw();
      return;
    }

    const minDelta = 1000 / lock;
    let raf = 0;
    let last: number | undefined;

    const tick = (now: number) => {
      raf = requestAnimationFrame(tick);
      last ??= now;
      const dt = now - last;
      if (dt < minDelta) return;
      last = now;
      time += (Math.min(dt, 100) / 1000) * speed;
      draw();
    };
    raf = requestAnimationFrame(tick);

    return () => cancelAnimationFrame(raf);
  });

  // 4) Перерисовка при смене параметров (важно, когда animated=false)
  $effect(() => {
    draw();
  });
</script>

<div
  bind:this={container}
  class="mesh-gradient {className}"
  style="width: {toCssSize(width)}; height: {toCssSize(height)}; {style}"
>
  <!-- CSS-фолбэк: виден до первого кадра и если WebGL недоступен -->
  <div class="fallback" style="background: {fallbackBg};" aria-hidden="true"></div>
  <canvas bind:this={canvas} class="canvas" aria-hidden="true"></canvas>

  <div class="content">
    {@render children?.()}
  </div>
</div>

<style>
  .mesh-gradient {
    position: relative;
    overflow: hidden;
  }

  .fallback,
  .canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
  }

  .content {
    position: relative;
    z-index: 1;
    width: 100%;
    height: 100%;
  }
</style>

Использование

<script lang="ts">
  import AnimatedMeshGradient from '$lib/components/ui-registry/Animatedmeshgradient.svelte';
</script>

<div class="h-[360px] overflow-hidden rounded-xl">
  <AnimatedMeshGradient speed={1} blur={0.4} noise={0.15}>
    <div class="flex h-full items-center justify-center">
      <h2 class="text-3xl font-semibold text-white">Привет, мир</h2>
    </div>
  </AnimatedMeshGradient>
</div>
<script lang="ts">
  import AnimatedMeshGradient from '$lib/components/ui-registry/Animatedmeshgradient.svelte';
</script>

<div class="h-[360px] overflow-hidden rounded-xl">
  <AnimatedMeshGradient speed={1} blur={0.4} noise={0.15}>
    <div class="flex h-full items-center justify-center">
      <h2 class="text-3xl font-semibold text-white">Привет, мир</h2>
    </div>
  </AnimatedMeshGradient>
</div>

Пропсы

Пропс Тип По умолчанию Описание
colors IMeshGradientColor[] 4 цвета До четырёх CSS-цветов или RGB-объектов
speed number 1 Скорость движения цветовых областей
noise number 0.15 Интенсивность зернистости (0–1)
blur number 0.4 Мягкость переходов (0–1)
contrast number 1 Контрастность цветов (0–2)
animated boolean true Включает или отключает анимацию
width / height number string 100% Размер контейнера; число задаёт пиксели
performance.undersampling number 0.5 Масштаб разрешения canvas (0.1–2)
performance.fpsLock number 60 Ограничение частоты кадров
class / style string — Класс и inline-стили контейнера
children Snippet — Контент поверх градиента

Особенности

  • WebGL-шейдер с CSS-градиентом в качестве фолбэка
  • Четыре настраиваемых цвета, мягкие переходы и регулируемый шум
  • Настройка разрешения canvas и ограничения FPS
  • Учитывает системную настройку prefers-reduced-motion
  • Контент отображается поверх фона через слот children