Lit Tubes
За курсором тянутся светящиеся трубки: голова идёт к указателю, хвост отстаёт цепочкой и плавно догоняет. Трубка здесь — настоящая геометрия с нормалью и светом, а не линия в плоском шейдере: у неё есть блик, теневая сторона и объём на повороте. Ось трубки живёт в маленькой текстуре (точка на тексель), а кольцевая оболочка строится в вершинном шейдере, поэтому на кадр не приходится ни одной перестройки геометрии. Свечение даёт вторая, раздутая копия той же трубки со сложением цветов. Без курсора трубки ходят собственными восьмёрками.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/lit-tubes.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 { createLitTubes, type LitTubes as Engine, type LitTubesOptions } from "@/lib/lit-tubes-engine";
export type LitTubesProps = Omit<LitTubesOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
const NIGHT =
"radial-gradient(120% 100% at 50% 55%, oklch(0.2 0.03 265) 0%, oklch(0.12 0.02 262) 50%, oklch(0.07 0.01 258) 100%)";
/**
* Lit Tubes — за курсором тянутся светящиеся трубки: голова идёт к указателю,
* хвост отстаёт цепочкой. Трубка — настоящая геометрия со светом и бликом,
* а не линия в плоском шейдере.
*
* Сцена — `createLitTubes` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function LitTubes({ backdrop = NIGHT, ...options }: LitTubesProps = {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engine = useRef<Engine | null>(null);
const first = useRef(options);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const animate = !(
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
engine.current = createLitTubes(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { tubes, radius, glow, chase, trail, hue, spread, speed } = options;
useEffect(() => {
engine.current?.set({ tubes, radius, glow, chase, trail, hue, spread, speed });
}, [tubes, radius, glow, chase, trail, hue, spread, speed]);
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
...(backdrop === false ? null : { background: backdrop }),
}}
>
<canvas ref={canvasRef} aria-hidden="true" style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
}
import * as THREE from "three";
/**
* Lit Tubes — движок на голом three.js (без React). За курсором тянутся светящиеся
* трубки: голова идёт к указателю, хвост отстаёт цепочкой и плавно догоняет.
*
* Трубка здесь — НАСТОЯЩАЯ ГЕОМЕТРИЯ с нормалью и светом, а не линия в плоском
* шейдере: поэтому у неё есть блик, теневая сторона и объём на повороте. Ось трубки
* живёт в маленькой текстуре (точка на тексель), а кольцевая оболочка строится
* в вершинном шейдере — так на кадр не приходится ни одной перестройки геометрии.
* Свечение даёт вторая, раздутая копия той же трубки со сложением цветов.
*
* `createLitTubes(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface LitTubesOptions {
/** Сколько трубок в кадре. */
tubes?: number;
/** Толщина трубки. */
radius?: number;
/** Сила свечения вокруг трубки. */
glow?: number;
/** Насколько быстро голова догоняет указатель. */
chase?: number;
/** Длина хвоста: доля цепочки, которая тянется следом. */
trail?: number;
/** Тон, 0–1 по кругу. */
hue?: number;
/** Разброс тона между трубками. */
spread?: number;
/** Скорость собственного движения, когда курсора нет. */
speed?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface LitTubes {
set(next: Partial<LitTubesOptions>): void;
destroy(): void;
}
const DEFAULTS = {
tubes: 6,
radius: 0.1,
glow: 0.8,
chase: 0.55,
trail: 0.8,
hue: 0.58,
spread: 0.22,
speed: 0.6,
animate: true,
};
type Config = typeof DEFAULTS;
/** Точек на ось трубки. Столько же текселей в строке текстуры оси. */
const POINTS = 48;
/** Вершин по кольцу оболочки. */
const RING = 10;
/** Больше трубок не строим: лишние просто не рисуются (`mesh.count`). */
const MAX_TUBES = 10;
/** Шаг симуляции. Фиксированный — иначе на лаге кадра цепочку выстреливает. */
const STEP = 1 / 120;
/** Минимальная длина звена: держит трубку растянутой, когда курсор стоит. */
const MIN_LINK = 0.07;
const VERT = /* glsl */ `
attribute float aSeg;
attribute float aAngle;
attribute float aTube;
uniform sampler2D uPoints;
uniform float uCols;
uniform float uRows;
uniform float uRadius;
uniform float uSwell;
uniform float uHue;
uniform float uSpread;
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vTint;
varying float vSeg;
vec3 fetch(float i, float row) {
return texture2D(uPoints, vec2((i + 0.5) / uCols, (row + 0.5) / uRows)).xyz;
}
// тон трубки: маленький сдвиг по кругу, чтобы пучок не был одноцветным
vec3 hue2rgb(float h) {
vec3 k = mod(vec3(5.0, 3.0, 1.0) + h * 6.0, 6.0);
return 1.0 - clamp(min(k, 4.0 - k), 0.0, 1.0);
}
void main() {
float t = aSeg * (uCols - 1.0);
float i = floor(t);
float f = t - i;
vec3 a = fetch(i, aTube);
vec3 b = fetch(min(i + 1.0, uCols - 1.0), aTube);
vec3 center = mix(a, b, f);
// Кадр трубки строится от касательной. Опорный вектор — ОСЬ КАМЕРЫ, а не «вверх»:
// трубки живут почти в плоскости кадра, и вертикальная опора у них переключалась
// прямо посреди дуги. Скачок опоры разворачивает оболочку на 90°, и трубку в этом
// месте видно сложенной, будто её разрезали. Уход в «вверх» плавный и только
// на участке, где трубка смотрит в камеру.
vec3 tangent = normalize(b - a + vec3(0.0, 0.0, 1e-4));
vec3 ref = normalize(mix(vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0), smoothstep(0.75, 0.97, abs(tangent.z))));
vec3 nx = normalize(cross(tangent, ref));
vec3 ny = cross(tangent, nx);
// Хвост тоньше головы, торцы — полусферы. Плоский срез на голове сразу читается
// «обрезанной лентой», а не трубкой.
float head = clamp(aSeg / 0.05, 0.0, 1.0);
float tail = clamp((1.0 - aSeg) / 0.05, 0.0, 1.0);
float cap = sqrt(max(0.0, 1.0 - (1.0 - head) * (1.0 - head)))
* sqrt(max(0.0, 1.0 - (1.0 - tail) * (1.0 - tail)));
float taper = (1.0 - aSeg * 0.55) * cap;
vec3 nrm = nx * cos(aAngle) + ny * sin(aAngle);
vec3 pos = center + nrm * uRadius * uSwell * taper;
vec4 world = modelMatrix * vec4(pos, 1.0);
vNormal = normalize(mat3(modelMatrix) * nrm);
vView = normalize(cameraPosition - world.xyz);
vTint = hue2rgb(fract(uHue + aTube * uSpread * 0.1));
vSeg = aSeg;
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
const SOLID_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uLight;
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vTint;
varying float vSeg;
void main() {
vec3 n = normalize(vNormal);
float key = max(0.0, dot(n, normalize(uLight)));
float rim = pow(1.0 - max(0.0, dot(n, normalize(vView))), 2.2);
float spec = pow(max(0.0, dot(reflect(-normalize(uLight), n), normalize(vView))), 42.0);
// ядро светится само, свет добавляет объём — без него трубка читается плоской лентой
vec3 core = mix(vTint, vec3(1.0), 0.35) * (0.42 + key * 0.7);
vec3 col = core + vTint * rim * 0.65 + vec3(spec) * 0.7;
col *= 1.0 - vSeg * 0.35;
gl_FragColor = vec4(col, 1.0);
}
`;
/** Свечение: та же трубка, раздутая и сложенная с кадром по кромке. */
const GLOW_FRAG = /* glsl */ `
precision highp float;
uniform float uGlow;
varying vec3 vNormal;
varying vec3 vView;
varying vec3 vTint;
varying float vSeg;
void main() {
// Ярче всего там, где раздутая оболочка смотрит В КАМЕРУ — то есть прямо над самой
// трубкой, и к кромке сходит на нет. Обратный вариант (свет по кромке оболочки)
// кажется естественным, но даёт кольцо-нимб с дырой посередине вместо свечения.
float face = max(0.0, dot(normalize(vNormal), normalize(vView)));
float a = pow(face, 3.0) * uGlow * (1.0 - vSeg * 0.5);
// Цвет УЖЕ умножен на альфу (premultipliedAlpha у материала). Если писать сюда
// альфу 1, холст с прозрачным фоном получает непрозрачный чёрный ореол вокруг
// трубки: подложка страницы сквозь него больше не видна.
gl_FragColor = vec4(vTint * a, a);
}
`;
/** Оболочка: кольца вдоль оси. Строится один раз, дальше её двигает только шейдер. */
function shellGeometry(): THREE.BufferGeometry {
const seg: number[] = [];
const ang: number[] = [];
const pos: number[] = [];
const idx: number[] = [];
for (let i = 0; i < POINTS; i++) {
for (let r = 0; r <= RING; r++) {
seg.push(i / (POINTS - 1));
ang.push((r / RING) * Math.PI * 2);
pos.push(0, 0, 0);
}
}
const row = RING + 1;
for (let i = 0; i < POINTS - 1; i++) {
for (let r = 0; r < RING; r++) {
const a = i * row + r;
const b = a + row;
idx.push(a, b, a + 1, a + 1, b, b + 1);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(pos), 3));
geo.setAttribute("aSeg", new THREE.BufferAttribute(new Float32Array(seg), 1));
geo.setAttribute("aAngle", new THREE.BufferAttribute(new Float32Array(ang), 1));
geo.setIndex(idx);
return geo;
}
export function createLitTubes(canvas: HTMLCanvasElement, options: LitTubesOptions = {}): LitTubes {
const cfg: Config = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 40);
camera.position.set(0, 0, 5.2);
/* ------------------------------------------------------------- ось трубок */
// строка текстуры = одна трубка, тексель = точка оси. Обновляется целиком за кадр:
// это 10×48 текселей, дешевле любой перестройки геометрии
const axis = new Float32Array(MAX_TUBES * POINTS * 4);
const axisTex = new THREE.DataTexture(axis, POINTS, MAX_TUBES, THREE.RGBAFormat, THREE.FloatType);
axisTex.minFilter = THREE.NearestFilter;
axisTex.magFilter = THREE.NearestFilter;
axisTex.wrapS = axisTex.wrapT = THREE.ClampToEdgeWrapping;
axisTex.needsUpdate = true;
const shared = {
uPoints: { value: axisTex },
uCols: { value: POINTS },
uRows: { value: MAX_TUBES },
uRadius: { value: cfg.radius },
uHue: { value: cfg.hue },
uSpread: { value: cfg.spread },
uLight: { value: new THREE.Vector3(-0.55, 0.72, 0.42).normalize() },
};
const geo = shellGeometry();
const tubeIds = new Float32Array(MAX_TUBES);
for (let i = 0; i < MAX_TUBES; i++) tubeIds[i] = i;
geo.setAttribute("aTube", new THREE.InstancedBufferAttribute(tubeIds, 1));
const solidMat = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: SOLID_FRAG,
uniforms: { ...shared, uSwell: { value: 1 } },
});
const glowMat = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: GLOW_FRAG,
uniforms: { ...shared, uSwell: { value: 3.6 }, uGlow: { value: cfg.glow } },
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
premultipliedAlpha: true,
});
const solid = new THREE.InstancedMesh(geo, solidMat, MAX_TUBES);
const glow = new THREE.InstancedMesh(geo, glowMat, MAX_TUBES);
solid.frustumCulled = false;
glow.frustumCulled = false;
glow.renderOrder = 1;
scene.add(solid, glow);
/* -------------------------------------------------------------- симуляция */
const chains: THREE.Vector3[][] = [];
const heads: THREE.Vector3[] = [];
for (let t = 0; t < MAX_TUBES; t++) {
const chain: THREE.Vector3[] = [];
const a = (t / MAX_TUBES) * Math.PI * 2;
for (let i = 0; i < POINTS; i++) chain.push(new THREE.Vector3(Math.cos(a) * 1.6, Math.sin(a) * 1.1, -i * 0.02));
chains.push(chain);
heads.push(new THREE.Vector3());
}
const pointer = { x: 0, y: 0, tx: 0, ty: 0, active: false };
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
pointer.tx = (e.clientX - r.left) / r.width - 0.5;
pointer.ty = 0.5 - (e.clientY - r.top) / r.height;
pointer.active = true;
};
const onLeave = () => {
pointer.active = false;
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onLeave);
let halfH = 2;
let halfW = 3;
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
halfH = Math.tan((camera.fov * Math.PI) / 360) * camera.position.z;
halfW = halfH * camera.aspect;
}
resize();
let raf = 0;
let last = 0;
let time = animate ? 0 : 3.6;
let acc = 0;
const d = new THREE.Vector3();
/** Один шаг цепочки: голова тянется к цели, каждое следующее звено — к предыдущему. */
function step(dt: number): void {
const count = Math.round(cfg.tubes);
// хвост длиннее — звенья догоняют медленнее, поэтому дуга получается длинной
const lag = 0.35 + (1 - cfg.trail) * 0.5;
for (let t = 0; t < count; t++) {
const chain = chains[t]!;
const phase = (t / Math.max(1, count)) * Math.PI * 2;
const head = heads[t]!;
if (pointer.active) {
// у каждой трубки своя точка вокруг курсора: иначе пучок слипается в одну линию
head.set(
pointer.x * halfW * 2 + Math.cos(time * 1.1 + phase) * 0.5,
pointer.y * halfH * 2 + Math.sin(time * 1.3 + phase) * 0.4,
Math.sin(time * 0.7 + phase) * 0.5,
);
} else {
// без курсора — своя восьмёрка, чтобы сцена жила
head.set(
Math.sin(time * 0.7 + phase) * halfW * 0.72,
Math.sin(time * 1.1 + phase * 1.3) * halfH * 0.72,
Math.cos(time * 0.5 + phase) * 0.7,
);
}
const k = Math.min(1, dt * (2.5 + cfg.chase * 9));
chain[0]!.lerp(head, k);
const follow = Math.min(1, dt * 60 * lag);
for (let i = 1; i < POINTS; i++) {
const prev = chain[i - 1]!;
const cur = chain[i]!;
cur.lerp(prev, follow);
// Нижний предел на звено. Без него цепочка на стоящем курсоре СХЛОПЫВАЕТСЯ
// в точку: трубка исчезает, а касательная вырождается. С ним хвост сворачивается
// кольцами и продолжает жить.
d.subVectors(cur, prev);
const len = d.length();
if (len < MIN_LINK) {
if (len > 1e-5) cur.copy(prev).addScaledVector(d.divideScalar(len), MIN_LINK);
else cur.set(prev.x + MIN_LINK, prev.y, prev.z);
}
}
}
}
/** Сглаживание оси: три точки в одну. Без него резкий поворот курсора даёт излом. */
function relax(): void {
const count = Math.round(cfg.tubes);
for (let t = 0; t < count; t++) {
const chain = chains[t]!;
for (let i = 1; i < POINTS - 1; i++) {
const p = chain[i]!;
const a = chain[i - 1]!;
const b = chain[i + 1]!;
p.set(p.x * 0.8 + (a.x + b.x) * 0.1, p.y * 0.8 + (a.y + b.y) * 0.1, p.z * 0.8 + (a.z + b.z) * 0.1);
}
}
}
function upload(): void {
const count = Math.round(cfg.tubes);
for (let t = 0; t < MAX_TUBES; t++) {
const chain = chains[Math.min(t, count - 1)]!;
for (let i = 0; i < POINTS; i++) {
const p = chain[i]!;
const o = (t * POINTS + i) * 4;
axis[o] = p.x;
axis[o + 1] = p.y;
axis[o + 2] = p.z;
axis[o + 3] = 1;
}
}
axisTex.needsUpdate = true;
}
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt * cfg.speed;
pointer.x += (pointer.tx - pointer.x) * 0.12;
pointer.y += (pointer.ty - pointer.y) * 0.12;
if (dt > 0) {
acc = Math.min(acc + dt, 0.2);
while (acc >= STEP) {
step(STEP);
acc -= STEP;
}
} else {
// стоячий кадр: догоняем цепочку до установившейся формы
for (let i = 0; i < 260; i++) step(STEP);
}
relax();
upload();
const count = Math.max(1, Math.min(MAX_TUBES, Math.round(cfg.tubes)));
solid.count = count;
glow.count = count;
shared.uRadius.value = cfg.radius;
shared.uHue.value = cfg.hue;
shared.uSpread.value = cfg.spread;
solidMat.uniforms.uRadius!.value = cfg.radius;
solidMat.uniforms.uHue!.value = cfg.hue;
solidMat.uniforms.uSpread!.value = cfg.spread;
glowMat.uniforms.uRadius!.value = cfg.radius;
glowMat.uniforms.uHue!.value = cfg.hue;
glowMat.uniforms.uSpread!.value = cfg.spread;
glowMat.uniforms.uGlow!.value = cfg.glow * 0.95;
renderer.render(scene, camera);
}
const loop = (now: number) => {
frame(now);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || !animate) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(() => {
resize();
frame();
});
ro.observe(canvas);
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(canvas);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
const onLost = (e: Event) => {
e.preventDefault();
stop();
};
canvas.addEventListener("webglcontextlost", onLost);
frame();
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<LitTubesOptions>;
Object.assign(cfg, patch);
if (!animate) frame();
},
destroy() {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
solid.dispose();
glow.dispose();
geo.dispose();
solidMat.dispose();
glowMat.dispose();
axisTex.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}