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

Flow Strands

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

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

Пропсы

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

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

Установка

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

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

Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.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 {
  /** Сколько лент. */
  count?: number;
  /** Извилистость: частота изгибов. */
  waviness?: number;
  /** Толщина ядра ленты. */
  thickness?: number;
  /** Размах по вертикали. */
  amplitude?: number;
  /** Сила свечения вокруг ядра. */
  glow?: number;
  /** Сдвиг палитры по кругу, 0–1. */
  hue?: number;
  /** Общая прозрачность. */
  opacity?: number;
  /** Скорость течения; 0 — стоп-кадр. */
  speed?: number;
  /** Масштаб сцены: больше — ленты крупнее и их видно меньше. */
  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источник: оригинал (Motiva)v1.0.0