Motiva
Мои коллекции
← каталог

Flow Strands

Светящиеся ленты, текущие через кадр: полноэкранный фрагментный шейдер на OGL. Линии считаются аналитически — сглаживание, свечение и толщина берутся из одного расстояния до кривой, без единого полигона.

oglautoparallaxadvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
count?number5Сколько лент (формат «5»).
waviness?number0.9Извилистость: частота изгибов (формат «1.6»).
thickness?number3.4Толщина ядра ленты (формат «2.6»).
amplitude?number1.3Размах по вертикали (формат «1.3»).
glow?number3.05Сила свечения вокруг ядра (формат «1.75»).
hue?number0.19Сдвиг палитры по кругу, 0–1 (формат «0.19»).
opacity?number0.95Общая прозрачность (формат «0.5»).
scale?number2.7Масштаб сцены (формат «2.7»): больше — ленты крупнее и их видно меньше.
backdrop?string | falseBACKDROPПодложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две подложки спорят за один слой.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/flow-strands.json

зависимости: ogl

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";
import { Mesh, Program, Renderer, Triangle } from "ogl";

/** Подложка по умолчанию — как в витрине. */
const BACKDROP =
  "var(--ds-bg, oklch(0.12 0.02 275))";

export interface FlowStrandsProps {
  /** Сколько лент (формат «5»). */
  count?: number;
  /** Извилистость: частота изгибов (формат «1.6»). */
  waviness?: number;
  /** Толщина ядра ленты (формат «2.6»). */
  thickness?: number;
  /** Размах по вертикали (формат «1.3»). */
  amplitude?: number;
  /** Сила свечения вокруг ядра (формат «1.75»). */
  glow?: number;
  /** Сдвиг палитры по кругу, 0–1 (формат «0.19»). */
  hue?: number;
  /** Общая прозрачность (формат «0.5»). */
  opacity?: number;
  /** Скорость течения (формат «0.35»); 0 — стоп-кадр. */
  speed?: number;
  /** Масштаб сцены (формат «2.7»): больше — ленты крупнее и их видно меньше. */
  scale?: number;
  /**
   * Подложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе.
   * `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две
   * подложки спорят за один слой.
   */
  backdrop?: string | false;
}

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

/**
 * Ленты считаются АНАЛИТИЧЕСКИ, а не рисуются геометрией: для каждого пикселя мы знаем,
 * на какой высоте проходит лента в этой колонке, и берём расстояние до неё. Отсюда и
 * бесплатное сглаживание (край — это `smoothstep` по расстоянию, а не пиксельная лесенка),
 * и свечение (та же величина в другой степени), и произвольная толщина без перестроения меша.
 *
 * Траектория — СУММА двух синусов с несоизмеримыми частотами. Один синус читается как
 * механическая волна: глаз мгновенно ловит период. Два с отношением частот 1 : 0.47 дают
 * рисунок, который не повторяется на глаз — это дешёвая замена шуму, стоящая две строки.
 */
const FRAG = /* glsl */ `
  precision highp float;

  uniform float uTime, uWaviness, uThickness, uAmplitude, uGlow, uHue, uOpacity, uScale;
  uniform int uCount;
  uniform vec2 uRes;
  varying vec2 vUv;

  vec3 hsv2rgb(vec3 c) {
    vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0);
    return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y);
  }

  void main() {
    vec2 uv = (vUv - 0.5) * vec2(uRes.x / uRes.y, 1.0) * uScale;

    vec3 col = vec3(0.0);
    float acc = 0.0;

    // Верхняя граница цикла в GLSL ES 1.0 обязана быть константой — число лент
    // приходит юниформой и обрывает цикл через break.
    for (int i = 0; i < 8; i++) {
      if (i >= uCount) break;
      float fi = float(i);
      float phase = fi * 0.9;

      float y = sin(uv.x * uWaviness + uTime + phase) * 0.34
              + sin(uv.x * uWaviness * 0.47 - uTime * 0.73 + phase * 1.7) * 0.21;
      y *= uAmplitude;
      y += (fi - (float(uCount) - 1.0) * 0.5) * 0.42;

      float d = abs(uv.y - y);
      float core = smoothstep(uThickness * 0.012, 0.0, d);
      float halo = pow(max(0.0, 1.0 - d / (uThickness * 0.05 * uGlow)), 2.5);
      float w = core + halo * 0.55;

      // тон плывёт и по номеру ленты, и вдоль неё — иначе получается плоская заливка
      col += hsv2rgb(vec3(fract(uHue + fi * 0.06 + uv.x * 0.04 + uTime * 0.012), 0.62, 1.0)) * w;
      acc += w;
    }

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

/**
 * Светящиеся ленты, текущие через кадр, — полноэкранный фрагментный шейдер на OGL.
 *
 * Почему OGL, а не three: здесь нужен ровно один полноэкранный треугольник и одна программа.
 * Three привёз бы 700 КБ сцены, камер и материалов ради того, чего в этой задаче нет.
 * OGL весит около десяти килобайт и даёт ту же пару Renderer + Program.
 *
 * Сцена ЗАСЫПАЕТ, и это не мелочь для фонового эффекта: кадры считаются только когда
 * холст в зоне видимости и вкладка активна. Фоновый шейдер, который продолжает крутиться
 * в свёрнутом окне, — самый частый способ незаметно съесть батарею у ноутбука.
 *
 * `prefers-reduced-motion` и `data-shot` рисуют ОДИН кадр и останавливаются: движение
 * выключено, а картинка на месте — так эффект остаётся виден в статике и на скриншотах.
 */
export function FlowStrands({
  count = 5,
  waviness = 0.9,
  thickness = 3.4,
  amplitude = 1.3,
  glow = 3.05,
  hue = 0.19,
  opacity = 0.95,
  speed = 0.9,
  scale = 2.7,
  backdrop = BACKDROP,
}: FlowStrandsProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  // пропсы читаем через ref: пересоздавать программу на каждое движение ползунка нельзя
  const cfg = useRef({ count, waviness, thickness, amplitude, glow, hue, opacity, speed, scale });
  cfg.current = { count, waviness, thickness, amplitude, glow, hue, opacity, speed, scale };

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

    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const renderer = new Renderer({ alpha: true, antialias: false, dpr: Math.min(2, window.devicePixelRatio || 1) });
    const gl = renderer.gl;
    gl.clearColor(0, 0, 0, 0);
    Object.assign(gl.canvas.style, { display: "block", width: "100%", height: "100%" });
    el.appendChild(gl.canvas);

    const program = new Program(gl, {
      vertex: VERT,
      fragment: FRAG,
      transparent: true,
      uniforms: {
        uTime: { value: 0 },
        uRes: { value: [1, 1] },
        uCount: { value: cfg.current.count },
        uWaviness: { value: cfg.current.waviness },
        uThickness: { value: cfg.current.thickness },
        uAmplitude: { value: cfg.current.amplitude },
        uGlow: { value: cfg.current.glow },
        uHue: { value: cfg.current.hue },
        uOpacity: { value: cfg.current.opacity },
        uScale: { value: cfg.current.scale },
      },
    });
    const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });

    const resize = () => {
      const { clientWidth: w, clientHeight: h } = el;
      if (!w || !h) return;
      renderer.setSize(w, h);
      program.uniforms.uRes!.value = [w, h];
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(el);

    let raf = 0;
    let last = performance.now();
    let time = 0;

    const draw = () => {
      const c = cfg.current;
      program.uniforms.uCount!.value = Math.round(c.count);
      program.uniforms.uWaviness!.value = c.waviness;
      program.uniforms.uThickness!.value = c.thickness;
      program.uniforms.uAmplitude!.value = c.amplitude;
      program.uniforms.uGlow!.value = c.glow;
      program.uniforms.uHue!.value = c.hue;
      program.uniforms.uOpacity!.value = c.opacity;
      program.uniforms.uScale!.value = c.scale;
      program.uniforms.uTime!.value = time;
      renderer.render({ scene: mesh });
    };

    const loop = (now: number) => {
      // время копим сами, а не берём из now: иначе после паузы лента прыгает
      // на весь простой разом
      time += Math.min(0.05, (now - last) / 1000) * cfg.current.speed;
      last = now;
      draw();
      raf = requestAnimationFrame(loop);
    };

    const start = () => {
      if (raf || frozen) return;
      last = performance.now();
      raf = requestAnimationFrame(loop);
    };
    const stop = () => {
      cancelAnimationFrame(raf);
      raf = 0;
    };

    draw(); // первый кадр всегда — даже в статике эффект должен быть виден

    // считаем кадры только когда холст в кадре И вкладка активна
    const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
    io.observe(el);
    const onVis = () => (document.hidden ? stop() : start());
    document.addEventListener("visibilitychange", onVis);

    return () => {
      stop();
      io.disconnect();
      ro.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      // контекстов на вкладку около шестнадцати — отпускаем явно, а не ждём сборщик
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      gl.canvas.remove();
    };
  }, []);

  return (
    <div
      ref={host}
      className="fst"
      style={{
        position: "absolute",
        inset: 0,
        pointerEvents: "none",
        ...(backdrop === false ? null : { background: backdrop }),
      }}
    />
  );
}
лицензия: MITавтор: Motivaисточник: идея-референс: эффект Strands из react-bits (MIT + Commons Clause — лицензия запрещает коммерциализировать сами компоненты, поэтому код НЕ заимствован). Шейдер, параметры и реализация — оригинальные (Motiva)v1.0.0