Silk
Атласная ткань: складки текут, и по их склонам бежит блик. Единственный фон каталога с моделью освещения — высотное поле складок (fbm с доменным искажением) даёт нормаль, дальше честный ламберт и два блика Блинна: широкий атласный перелив и узкая искра на гребнях. Повернёшь свет — перелив уедет за ним.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| scale? | number | 1.6 | Крупность складок: 1 — широкие волны, 4 — мелкая рябь. |
| sheen? | number | 0.75 | Сила блика: 0 — матовая ткань, 1 — атлас. |
| lightAngle? | number | 35 | Направление света, градусы. |
| intensity? | number | 1 | Общая яркость. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/silk.jsonзависимости: ogl
Что нужно в проекте до установки
Файл
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 { Mesh, Program, Renderer, Triangle } from "ogl";
export interface SilkProps {
/** Крупность складок: 1 — широкие волны, 4 — мелкая рябь. */
scale?: number;
/** Скорость течения; 0 — стоп-кадр. */
speed?: number;
/** Тон ткани, 0–1 по кругу; 0.78 — сирень. */
hue?: number;
/** Сила блика: 0 — матовая ткань, 1 — атлас. */
sheen?: number;
/** Направление света, градусы. */
lightAngle?: number;
/** Общая яркость. */
intensity?: number;
}
const VERT = /* glsl */ `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;
/**
* Разность приёма со всеми фонами каталога: shader-flow и aurora-field СВЕТЯТСЯ —
* цвет там назначен яркости напрямую. Здесь ткань ОСВЕЩЕНА: шейдер строит высотное
* поле складок, берёт из его градиента нормаль и считает честное освещение —
* ламберт + блик Блинна. Перелив атласа — это не палитра, а блик, бегущий по
* склонам складок; повернёшь свет — перелив уедет за ним.
*
* Складки — доменное искажение: fbm читается в точке, сдвинутой другим fbm.
* Прямой fbm даёт мятую бумагу (равномерные бугры); искажение вытягивает бугры
* в длинные жгуты — так и лежит ткань. Анизотропия по той же причине: поле сжато
* вдоль одной оси, у складок появляется направление.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime, uScale, uHue, uSheen, uLightAngle, uIntensity;
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);
}
float hash21(vec2 p) {
p = fract(p * vec2(234.34, 435.345));
p += dot(p, p + 34.23);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i), b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0)), d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
// затухание октав 0.42, а не 0.5: мелкая рябь глушится, остаются крупные жгуты
float fbm(vec2 p) {
float v = 0.0, a = 0.54;
for (int i = 0; i < 4; i++) {
v += vnoise(p) * a;
p = p * 2.03 + vec2(7.3, 3.1);
a *= 0.42;
}
return v;
}
// высота ткани в точке: fbm, прочитанный со сдвигом на другой fbm.
// сжатие по y ДО искажения — у складок появляется направление вдоль кадра.
// искажение НЕБОЛЬШОЕ (1.1): сильное рвёт складки в турбулентность, и вместо
// лежащей ткани выходит жидкий мрамор — ткань это ДЛИННЫЕ гладкие жгуты
float height(vec2 p, float t) {
vec2 q = p * vec2(0.85, 2.5);
vec2 warp = vec2(fbm(q + vec2(t * 0.5, 0.0)), fbm(q + vec2(5.2, t * 0.35)));
return fbm(q + warp * 1.1 - vec2(t * 0.22, 0.0));
}
void main() {
float aspect = uRes.x / uRes.y;
vec2 p = vec2(vUv.x * aspect, vUv.y) * uScale;
float t = uTime;
// нормаль из градиента высоты; шаг производной в НОРМИРОВАННЫХ координатах,
// чтобы рельеф не менял крутизну от размера окна
float e = 0.014;
float h = height(p, t);
float hx = height(p + vec2(e, 0.0), t) - h;
float hy = height(p + vec2(0.0, e), t) - h;
// амплитуда рельефа: складки достаточно крутые для блика, но без чёрных провалов
vec3 n = normalize(vec3(-hx * 3.6, -hy * 3.6, e * 7.0));
// свет ПОЛОГИЙ (z 0.5): скользящее освещение — то, что делает атлас атласом,
// отвесный свет заливает складки ровно и убивает перелив
float la = radians(uLightAngle);
vec3 L = normalize(vec3(cos(la), sin(la), 0.5));
vec3 V = vec3(0.0, 0.0, 1.0);
vec3 H = normalize(L + V);
float diff = clamp(dot(n, L), 0.0, 1.0);
// два блика на одной нормали: широкий мягкий даёт атласный перелив,
// узкий — влажную искру на гребнях. Показатели степени и есть «материал»
float specWide = pow(clamp(dot(n, H), 0.0, 1.0), 13.0);
float specTight = pow(clamp(dot(n, H), 0.0, 1.0), 140.0);
// сама ткань: тон один, светлота от освещения, в провалах складок глубже и чуть
// насыщеннее — краситель в тени всегда выглядит гуще
float shade = 0.16 + 0.5 * diff;
float sat = 0.42 + 0.2 * (1.0 - diff);
vec3 cloth = hsv2rgb(vec3(uHue, sat, shade));
// блик тянет к белому с лёгким сдвигом тона — перелив, а не белая клякса
vec3 sheenCol = hsv2rgb(vec3(fract(uHue + 0.06), 0.18, 1.0));
// узкая «мокрая» искра приглушена: она даёт влажный пластик, атлас держится широким
vec3 col = cloth + sheenCol * (specWide * 1.0 + specTight * 0.22) * uSheen;
col *= uIntensity;
// тональная компрессия: блик поверх светлой складки не выгорает в плоское пятно
col = vec3(1.0) - exp(-col * 1.9);
gl_FragColor = vec4(col, 1.0);
}
`;
/**
* Атласная ткань: складки текут, и по их склонам бежит блик.
*
* Единственный фон каталога с моделью освещения: высотное поле складок (fbm с доменным
* искажением) → нормаль из градиента → ламберт + два блика Блинна на одной нормали
* (широкий — атласный перелив, узкий — искра на гребнях). Кадр непрозрачный: ткань
* закрывает всё, альфа-канала и подложки нет.
*
* Сцена засыпает вне зоны видимости и на неактивной вкладке. `prefers-reduced-motion`
* и `data-shot` рисуют один кадр.
*/
export function Silk({
scale = 1.6,
speed = 0.3,
hue = 0.78,
sheen = 0.75,
lightAngle = 35,
intensity = 1,
}: SilkProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ scale, speed, hue, sheen, lightAngle, intensity });
cfg.current = { scale, speed, hue, sheen, lightAngle, intensity };
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: false, antialias: false, dpr: Math.min(2, window.devicePixelRatio || 1) });
const gl = renderer.gl;
Object.assign(gl.canvas.style, { display: "block", width: "100%", height: "100%" });
el.appendChild(gl.canvas);
const program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
uniforms: {
uTime: { value: 0 },
uRes: { value: [1, 1] },
uScale: { value: scale },
uHue: { value: hue },
uSheen: { value: sheen },
uLightAngle: { value: lightAngle },
uIntensity: { value: intensity },
},
});
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.uScale!.value = c.scale;
program.uniforms.uHue!.value = c.hue;
program.uniforms.uSheen!.value = c.sheen;
program.uniforms.uLightAngle!.value = c.lightAngle;
program.uniforms.uIntensity!.value = c.intensity;
program.uniforms.uTime!.value = time;
renderer.render({ scene: mesh });
};
const loop = (now: number) => {
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="slk" style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />;
}