Chrome Type
Заголовок как отливка из жидкого хрома. Буквы рисуются в canvas и работают полем высоты: из его градиента считается нормаль, а цвет даёт отражение процедурного студийного окружения — тёплый беж внизу, лёд сверху, лаванда в тени, две софт-лампы. Движения три и они разной природы: металл сам оплывает шумом, окружение вращается и блик проезжает по буквам, курсор тянет поверхность к себе. Кромки расходятся в спектр дисперсией.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/chrome-type.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 { createChromeType, type ChromeType as Engine, type ChromeTypeOptions } from "@/lib/chrome-type-engine";
export type ChromeTypeProps = Omit<ChromeTypeOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
const STUDIO =
"radial-gradient(120% 100% at 50% 0%, oklch(0.28 0.03 280) 0%, oklch(0.16 0.02 275) 45%, oklch(0.09 0.01 265) 100%)";
/**
* Chrome Type — заголовок как отливка из жидкого хрома: буквы служат полем высоты,
* из его градиента берётся нормаль, цвет даёт отражение процедурного студийного
* окружения. Металл течёт, блик проезжает по буквам, курсор тянет поверхность к себе.
*
* Сцена — `createChromeType` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function ChromeType({ backdrop = STUDIO, ...options }: ChromeTypeProps = {}) {
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 = createChromeType(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { lines, melt, speed, tint, gloss, dispersion, pull, relief, font } = options;
useEffect(() => {
engine.current?.set({ lines, melt, speed, tint, gloss, dispersion, pull, relief, font });
}, [lines, melt, speed, tint, gloss, dispersion, pull, relief, font]);
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";
/**
* Chrome Type — движок на голом three.js (без React). Заголовок превращается в отливку
* из жидкого хрома: буквы рисуются в canvas и служат ВЫСОТНЫМ ПОЛЕМ, из его градиента
* считается нормаль, а цвет берётся отражением процедурного студийного окружения.
*
* `createChromeType(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface ChromeTypeOptions {
/** Строки заголовка: каждая — своя строка отливки. */
lines?: string[];
/** Текучесть металла: насколько буквы плывут и оплывают. */
melt?: number;
/** Скорость течения и проезда блика; 0 — стоп. */
speed?: number;
/** Тон окружения, 0–1 по кругу: 0.62 — лавандово-ледяной. */
tint?: number;
/** Полировка: выше — резче блики и жёстче контраст металла. */
gloss?: number;
/** Дисперсия по кромкам: расхождение каналов в отражении. */
dispersion?: number;
/** Насколько металл тянется к курсору. */
pull?: number;
/** Толщина отливки: как высоко буквы стоят над плоскостью. */
relief?: number;
/** Начертание: CSS-стек шрифта. */
font?: string;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface ChromeType {
set(next: Partial<ChromeTypeOptions>): void;
destroy(): void;
}
const DEFAULTS: Required<ChromeTypeOptions> = {
lines: ["ЖИДКИЙ", "ХРОМ"],
melt: 0.5,
speed: 0.6,
tint: 0.62,
gloss: 1,
dispersion: 0.45,
pull: 0.7,
relief: 1,
font: '800 {size}px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
animate: true,
};
const VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
/**
* Весь приём живёт здесь. Буквы — не геометрия, а поле высоты `uField`: внутри 1,
* к кромке плавно к нулю (плечи нарисованы на canvas тремя размытиями). Из градиента
* поля берётся нормаль, по нормали — отражение, и плоский текст читается как отливка.
*
* Три источника движения, намеренно разной природы: поле подмешивает ТЕЧЕНИЕ (uv
* сдвигается шумом — металл оплывает), окружение ВРАЩАЕТСЯ (блик проезжает по буквам,
* сама форма при этом стоит), курсор ТЯНЕТ поле к себе (локальный наплыв). Если двигать
* только одно, сцена читается как картинка с фильтром.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uField;
uniform vec2 uRes;
uniform vec2 uMouse;
uniform float uTime;
uniform float uMelt;
uniform float uTint;
uniform float uGloss;
uniform float uDispersion;
uniform float uPull;
uniform float uRelief;
varying vec2 vUv;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
float fbm(vec2 p) {
return noise(p) * 0.55 + noise(p * 2.03 + 11.3) * 0.28 + noise(p * 4.11 + 37.1) * 0.17;
}
/**
* Студийное окружение как функция направления: без HDR-картинки и кубмапы.
* Хром узнаётся не блеском, а КОНТРАСТОМ отражённого: тёмный пол, светлый потолок
* и резкая линия горизонта между ними. Ровный светлый тон даёт не металл,
* а глянцевый пластик — это отличие и делает запись.
*/
vec3 env(vec3 r) {
// софт-палитра тренда вместо киберпанка: тёплый беж, лёд, лаванда
vec3 floorC = vec3(0.09, 0.08, 0.14);
vec3 ceilC = vec3(0.66, 0.82, 1.00);
vec3 warm = vec3(1.00, 0.86, 0.68);
vec3 lav = vec3(0.55, 0.44, 0.92);
float hue = uTint * 6.28318;
// поворот тона: тем же окружением получается и золото, и сталь
ceilC = mix(ceilC, vec3(1.00, 0.94, 0.80), 0.5 + 0.5 * sin(hue));
warm = mix(warm, lav, 0.5 + 0.5 * cos(hue));
float y = r.y;
vec3 sky = mix(floorC, ceilC, smoothstep(-0.12, 0.3, y));
sky = mix(sky, warm, smoothstep(0.0, -0.75, y) * 0.55); // тёплый пол
sky += vec3(1.0, 0.99, 0.96) * smoothstep(0.55, 0.98, y) * 0.85; // потолочный свет
sky *= 1.0 - 0.55 * exp(-abs(y) * 26.0); // линия горизонта
// две софт-лампы: узкая сверху-слева и широкая справа
float lampA = pow(max(dot(r, normalize(vec3(-0.45, 0.8, 0.42))), 0.0), 26.0 * uGloss);
float lampB = pow(max(dot(r, normalize(vec3(0.62, 0.18, 0.76))), 0.0), 9.0 * uGloss);
return sky + vec3(1.0, 0.97, 0.92) * lampA * 1.7 + vec3(0.86, 0.92, 1.0) * lampB * 0.45;
}
/**
* Наклон полированной поверхности, НЕ связанный с формой буквы: внутри грани
* градиент поля равен нулю, и без этой волны вся грань отражает одно направление —
* металл выглядит серым пластиком. Волна медленно течёт, и блик ползёт по буквам.
*/
vec2 waveSlope(vec2 uv, float t) {
vec2 drift = vec2(t * 0.05, -t * 0.04);
float e = 0.012;
// низкая частота даёт большие плавные заливки отражения, высокая — мелкие струи
float w = fbm((uv + drift) * 1.7) * 0.75 + fbm((uv - drift) * 5.3) * 0.25;
float wx = fbm((uv + vec2(e, 0.0) + drift) * 1.7) * 0.75 + fbm((uv + vec2(e, 0.0) - drift) * 5.3) * 0.25;
float wy = fbm((uv + vec2(0.0, e) + drift) * 1.7) * 0.75 + fbm((uv + vec2(0.0, e) - drift) * 5.3) * 0.25;
return vec2(w - wx, w - wy) / e;
}
vec3 rotY(vec3 v, float a) {
float c = cos(a), s = sin(a);
return vec3(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
}
/** Поле высоты в точке с учётом течения и тяги к курсору. */
float field(vec2 uv, float t) {
vec2 flow = vec2(fbm(uv * 2.6 + vec2(0.0, t * 0.12)), fbm(uv * 2.6 + 19.7 - vec2(t * 0.09, 0.0))) - 0.5;
// вниз металл оплывает охотнее, чем вверх — иначе движение читается как дрожь
flow.y -= 0.12;
vec2 p = uv + flow * uMelt * 0.055;
vec2 d = uv - uMouse;
d.x *= uRes.x / uRes.y;
float pull = exp(-dot(d, d) * 14.0) * uPull;
p += normalize(uv - uMouse + 1e-5) * pull * 0.045;
return clamp(texture2D(uField, p).r, 0.0, 1.0);
}
void main() {
float t = uTime;
vec2 px = 1.0 / uRes;
float h = field(vUv, t);
// нормаль из градиента поля: центральные разности по двум осям
float hx = field(vUv + vec2(px.x * 1.6, 0.0), t) - field(vUv - vec2(px.x * 1.6, 0.0), t);
float hy = field(vUv + vec2(0.0, px.y * 1.6), t) - field(vUv - vec2(0.0, px.y * 1.6), t);
// масштаб градиента привязан к пикселю: иначе рельеф «худеет» на больших экранах
vec2 slope = vec2(-hx / px.x, -hy / px.y) * 0.0019 * uRelief;
// волна работает только ВНУТРИ отливки, за кромкой ей нечего наклонять
slope += waveSlope(vUv, t) * 0.16 * (0.45 + uMelt) * smoothstep(0.35, 0.75, h);
vec3 n = normalize(vec3(slope, 1.0));
vec3 view = normalize(vec3((vUv - 0.5) * vec2(uRes.x / uRes.y, 1.0) * 0.55, -1.0));
vec3 r = rotY(reflect(view, n), t * 0.25);
float fres = pow(1.0 - max(dot(n, -view), 0.0), 3.0);
// дисперсия: каналы отражаются от чуть разных нормалей, кромки расходятся в спектр
float sp = uDispersion * 0.06;
vec3 col = vec3(
env(rotY(reflect(view, normalize(n + vec3(sp, 0.0, 0.0))), t * 0.25)).r,
env(r).g,
env(rotY(reflect(view, normalize(n - vec3(sp, 0.0, 0.0))), t * 0.25)).b
);
col += fres * vec3(0.9, 0.94, 1.0) * 0.5;
// контактная тень под отливкой: без неё буквы висят в воздухе
float shade = smoothstep(0.02, 0.5, h) * 0.35;
vec3 backdrop = vec3(0.0);
float alpha = smoothstep(0.30, 0.44, h);
col = vec3(1.0) - exp(-col * 1.35); // тональная компрессия вместо clamp
gl_FragColor = vec4(mix(backdrop, col, alpha), max(alpha, shade * 0.6));
}
`;
/** Рисует строки в canvas и превращает их в поле высоты: 1 внутри, плечи к кромке. */
function paintField(ctx: CanvasRenderingContext2D, lines: string[], font: string, w: number, h: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.filter = "none";
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, w, h);
const rows = lines.length || 1;
// кегль подбирается по самой длинной строке и по высоте кадра
let size = Math.min((h / rows) * 0.86, w * 0.42);
const measure = (s: number) => {
ctx.font = font.replace("{size}", String(s));
return Math.max(...lines.map((l) => ctx.measureText(l).width));
};
const margin = w * 0.9;
for (let i = 0; i < 24 && measure(size) > margin; i++) size *= margin / measure(size);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#fff";
const lineH = size * 0.94;
const draw = () => {
ctx.font = font.replace("{size}", String(size));
lines.forEach((line, i) => ctx.fillText(line, w / 2, h / 2 + (i - (rows - 1) / 2) * lineH));
};
// плечи отливки: одно и то же слово несколько раз с растущим размытием, сложением.
// Дешевле любого distance field и даёт именно скруглённую фаску, а не плоский срез.
ctx.globalCompositeOperation = "lighter";
const k = size / 120;
for (const [blur, alpha] of [
[0, 1],
[3 * k, 0.55],
[9 * k, 0.38],
[22 * k, 0.24],
] as [number, number][]) {
ctx.filter = blur > 0.01 ? `blur(${blur}px)` : "none";
ctx.globalAlpha = alpha;
draw();
}
ctx.filter = "none";
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
export function createChromeType(canvas: HTMLCanvasElement, options: ChromeTypeOptions = {}): ChromeType {
const cfg: Required<ChromeTypeOptions> = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: "high-performance" });
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(dpr);
renderer.setClearColor(0, 0);
const field = document.createElement("canvas");
const fctx = field.getContext("2d")!;
const fieldTex = new THREE.CanvasTexture(field);
fieldTex.minFilter = THREE.LinearFilter;
fieldTex.magFilter = THREE.LinearFilter;
fieldTex.generateMipmaps = false;
fieldTex.wrapS = fieldTex.wrapT = THREE.ClampToEdgeWrapping;
const uniforms = {
uField: { value: fieldTex },
uRes: { value: new THREE.Vector2(1, 1) },
uMouse: { value: new THREE.Vector2(0.5, 0.62) },
uTime: { value: 0 },
uMelt: { value: cfg.melt },
uTint: { value: cfg.tint },
uGloss: { value: cfg.gloss },
uDispersion: { value: cfg.dispersion },
uPull: { value: cfg.pull },
uRelief: { value: cfg.relief },
};
const scene = new THREE.Scene();
const camera = new THREE.Camera();
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms,
transparent: true,
});
scene.add(new THREE.Mesh(geometry, material));
let width = 1;
let height = 1;
function repaint(): void {
// поле рисуется в пикселях устройства: буквы должны быть резкими, а не мыльными
field.width = Math.max(2, Math.round(width * dpr));
field.height = Math.max(2, Math.round(height * dpr));
paintField(fctx, cfg.lines, cfg.font, field.width, field.height);
fieldTex.needsUpdate = true;
}
function resize(): void {
width = canvas.clientWidth || window.innerWidth;
height = canvas.clientHeight || window.innerHeight;
renderer.setSize(width, height, false);
uniforms.uRes.value.set(width * dpr, height * dpr);
repaint();
}
const pointer = { x: 0.5, y: 0.62, tx: 0.5, ty: 0.62 };
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
pointer.tx = (e.clientX - r.left) / r.width;
pointer.ty = 1 - (e.clientY - r.top) / r.height;
};
const onPointerLeave = () => {
pointer.tx = 0.5;
pointer.ty = 0.62;
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onPointerLeave);
let raf = 0;
let last = 0;
const frame = (now = 0) => {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
uniforms.uTime.value += dt * cfg.speed;
// курсор доезжает инерционно: металл тянется, а не дёргается за мышью
pointer.x += (pointer.tx - pointer.x) * 0.08;
pointer.y += (pointer.ty - pointer.y) * 0.08;
uniforms.uMouse.value.set(pointer.x, pointer.y);
uniforms.uMelt.value = cfg.melt;
uniforms.uTint.value = cfg.tint;
uniforms.uGloss.value = cfg.gloss;
uniforms.uDispersion.value = cfg.dispersion;
uniforms.uPull.value = cfg.pull;
uniforms.uRelief.value = cfg.relief;
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);
resize();
frame();
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);
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<ChromeTypeOptions>;
const redraw =
(patch.lines !== undefined && patch.lines.join(" ") !== cfg.lines.join(" ")) ||
(patch.font !== undefined && patch.font !== cfg.font);
Object.assign(cfg, patch);
if (redraw) repaint();
if (!animate || redraw) frame();
},
destroy() {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onPointerLeave);
canvas.removeEventListener("webglcontextlost", onLost);
geometry.dispose();
material.dispose();
fieldTex.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}