← каталог
Flow Strands
Светящиеся ленты, текущие через кадр: полноэкранный фрагментный шейдер на OGL. Линии считаются аналитически — сглаживание, свечение и толщина берутся из одного расстояния до кривой, без единого полигона.
oglautoparallaxadvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| count? | number | 5 | Сколько лент (формат «5»). |
| waviness? | number | 0.9 | Извилистость: частота изгибов (формат «1.6»). |
| thickness? | number | 3.4 | Толщина ядра ленты (формат «2.6»). |
| amplitude? | number | 1.3 | Размах по вертикали (формат «1.3»). |
| glow? | number | 3.05 | Сила свечения вокруг ядра (формат «1.75»). |
| hue? | number | 0.19 | Сдвиг палитры по кругу, 0–1 (формат «0.19»). |
| opacity? | number | 0.95 | Общая прозрачность (формат «0.5»). |
| scale? | number | 2.7 | Масштаб сцены (формат «2.7»): больше — ленты крупнее и их видно меньше. |
| backdrop? | string | false | BACKDROP | Подложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две подложки спорят за один слой. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/flow-strands.jsonзависимости: ogl
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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