Shader Flow
Full-bleed WebGL-градиент на голом three.js: плавно перетекающая плазма с IQ-палитрой. Настоящий шейдер, не CSS.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/shader-flow.jsonзависимости: three@^0.185.1 · @types/three@^0.185.0
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
export interface ShaderFlowProps {
/** Скорость перетекания; 0 — стоп-кадр. */
speed?: number;
}
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;
uniform float uTime;
varying vec2 vUv;
// IQ cosine palette
vec3 palette(float t) {
return vec3(0.5) + vec3(0.5) * cos(6.28318 * (vec3(1.0) * t + vec3(0.0, 0.33, 0.67)));
}
void main() {
vec2 uv = vUv * 2.0 - 1.0;
float t = uTime * 0.15;
// domain warp → плавные перетекающие полосы
float w = sin(uv.x * 3.0 + t) + cos(uv.y * 3.0 - t * 1.3);
float n = sin(uv.x * 4.0 + w + t) * cos(uv.y * 4.0 - w - t);
vec3 col = palette(0.55 + 0.45 * n + t * 0.1);
col *= 1.0 - 0.35 * dot(uv, uv); // виньетка
gl_FragColor = vec4(col, 1.0);
}
`;
/**
* Full-bleed WebGL-градиент на голом three.js (без React Three Fiber).
*
* Один полноэкранный треугольник-плоскость: вершинный шейдер отдаёт координаты кадра
* как есть, поэтому ни камера, ни пропорции в математику не входят — цвет считает
* фрагментный шейдер по искажённой области (domain warp) и косинусной палитре.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `prefers-reduced-motion`
* и `data-shot` рисуют один кадр.
*/
export function ShaderFlow({ speed = 1 }: ShaderFlowProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ speed });
cfg.current = { speed };
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 THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
Object.assign(renderer.domElement.style, { display: "block", width: "100%", height: "100%" });
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
// камера-заглушка: позиции вершин уже в координатах кадра
const camera = new THREE.Camera();
const uniforms = { uTime: { value: 0 } };
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms });
scene.add(new THREE.Mesh(geometry, material));
const resize = () => {
const { clientWidth: w, clientHeight: h } = el;
if (!w || !h) return;
renderer.setSize(w, h, false);
renderer.render(scene, camera);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(el);
let raf = 0;
let last = 0;
const loop = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
uniforms.uTime.value += dt * cfg.current.speed;
renderer.render(scene, camera);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || frozen) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
renderer.render(scene, camera);
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);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
};
}, []);
return <div ref={host} style={{ position: "absolute", inset: 0 }} />;
}