← каталог
Aurora Field
Северное сияние одним фрагментным шейдером: у занавеса резкая нижняя кромка и вертикальные лучи разной длины над ней, а цвет переливается по высоте — розовая кромка, зелёное ядро, фиолетовые верхушки.
oglautoparallaxadvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| bands? | number | 3 | Число занавесов (формат «3»). |
| height? | number | 0.55 | Длина лучей вверх от кромки, доля кадра (формат «0.55»). |
| intensity? | number | 1 | Яркость (формат «1»). |
| hue? | number | 0.36 | Тон нижней кромки, 0–1 по кругу (формат «0.36» — зелёный). |
| spread? | number | 0.2 | Насколько тон уходит вверх по занавесу: зелёный → бирюза → фиолет (формат «0.2»). |
| backdrop? | string | false | NIGHT | Подложка-небо: любое CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и небо задаёт она — иначе две подложки спорят за один слой. В витрине небо своё: сияние без ночи не читается. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/aurora-field.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";
export interface AuroraFieldProps {
/** Число занавесов (формат «3»). */
bands?: number;
/** Скорость течения (формат «0.22»); 0 — стоп-кадр. */
speed?: number;
/** Длина лучей вверх от кромки, доля кадра (формат «0.55»). */
height?: number;
/** Мягкость нижней кромки (формат «0.5»); 0 — резкая линия. */
softness?: number;
/** Контраст вертикальных лучей (формат «0.8»); 0 — сплошная лента без структуры. */
rays?: number;
/** Сила дымки вокруг занавеса (формат «0.9»); 0 — только лучи, без ореола. */
glow?: number;
/** Яркость (формат «1»). */
intensity?: number;
/** Тон нижней кромки, 0–1 по кругу (формат «0.36» — зелёный). */
hue?: number;
/** Насколько тон уходит вверх по занавесу: зелёный → бирюза → фиолет (формат «0.2»). */
spread?: number;
/**
* Подложка-небо: любое CSS-значение `background` или `false`, чтобы не рисовать её вовсе.
* `false` нужен, когда запись стоит фоном СЕКЦИИ и небо задаёт она — иначе две подложки
* спорят за один слой. В витрине небо своё: сияние без ночи не читается.
*/
backdrop?: string | false;
}
/**
* Ночное небо намеренно НЕ берёт `--ds-bg`: сияние бывает только ночью, и на светлой теме
* занавесы висели над белым полем.
*/
const NIGHT =
"linear-gradient(to bottom, oklch(0.15 0.045 272) 0%, oklch(0.11 0.035 268) 45%, oklch(0.07 0.02 262) 100%)";
const VERT = /* glsl */ `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;
/**
* Анатомия настоящего сияния, а не «светящаяся полоса»: у занавеса есть НИЗ и ВЕРХ, и они
* устроены по-разному. Внизу — резкая кромка (там частицы врезаются в плотный воздух),
* вверх от неё тянутся вертикальные лучи разной длины и постепенно растворяются. Поэтому
* профиль здесь асимметричный: вниз от кромки — узкий гаусс, вверх — длинный экспоненциальный
* хвост. Симметричный гаусс (расстояние до линии) даёт ровно тот «бесструктурный светящийся
* ком», ради ухода от которого всё и переписано.
*
* Структуру дают ДВА независимых шума от одной координаты вдоль занавеса: один правит яркость
* луча, второй — его длину. Разная длина соседних лучей и есть та рваная верхняя граница,
* по которой глаз опознаёт сияние. Значение шума (hash + сглаженная интерполяция) взято вместо
* Перлина сознательно: тут нужна 1D-функция, и она обходится в пару операций.
*
* Цвет — функция ВЫСОТЫ внутри занавеса, а не номера занавеса: розовая кромка снизу
* (свечение азота), зелёное ядро (кислород 557.7 нм), к верхушкам — бирюза и фиолет.
* Это и есть «переливы»: один занавес переливается сам по себе.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime, uHeight, uSoft, uIntensity, uHue, uSpread, uGlow, uRays;
uniform int uBands;
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 hash11(float p) {
p = fract(p * 0.1031);
p *= p + 33.33;
return fract((p + p) * p);
}
// значение-шум: та же цена, что синус, но без периода — лучи не выстраиваются в частокол
float vnoise(float x) {
float i = floor(x), f = fract(x);
f = f * f * (3.0 - 2.0 * f);
return mix(hash11(i), hash11(i + 1.0), f);
}
float fbm(float x) {
return vnoise(x) * 0.55 + vnoise(x * 2.07 + 9.1) * 0.29 + vnoise(x * 4.13 + 31.7) * 0.16;
}
void main() {
vec2 uv = vUv;
float aspect = uRes.x / uRes.y;
float x = uv.x * aspect;
vec3 col = vec3(0.0);
for (int i = 0; i < 6; i++) {
if (i >= uBands) break;
float fi = float(i);
float t = uTime + fi * 17.3;
// дальние занавесы: выше в кадре, бледнее, складки мельче. Отсюда ощущение глубины
// без второго прохода и без единого лишнего пикселя
float depth = fi / max(1.0, float(uBands) - 1.0);
float near = 1.0 - depth * 0.5;
// нижняя кромка занавеса — складки драпировки. Размах намеренно небольшой: сияние
// читается лучами, а крупная волна превращает его в ленту-змею. Шумовое слагаемое
// обязательно: на одних синусах кромка идёт через кадр безупречной волной и выдаёт
// математику
float ridge = 0.22 + depth * 0.22
+ sin(x * (1.15 + depth * 0.5) + t * 0.5 + fi) * 0.06 * near
+ sin(x * (2.6 + depth * 0.9) - t * 0.33 + fi * 2.3) * 0.03
+ fbm(x * 1.9 + t * 0.25 + fi * 23.0) * 0.06 - 0.03;
// лучи идут по магнитным линиям и к верху расходятся веером — координата луча берётся
// с поправкой на высоту над кромкой, иначе они стоят как забор из одинаковых палок
float above = max(uv.y - ridge, 0.0);
float xs = aspect * 0.5 + (x - aspect * 0.5) / (1.0 + above * 0.45);
// координата ВДОЛЬ занавеса: лучи медленно текут по нему, не сдвигая саму драпировку
float rc = xs * (19.0 + depth * 7.0) + t * 0.8 + fi * 53.0;
// тонкий луч = резкий пик: степень 3 сжимает гладкий шум в узкие гребни с тёмными
// промежутками. Без неё лучи расплываются в вертикальные мазки
float rayLit = pow(vnoise(rc) * 0.62 + fbm(rc * 0.21 + 5.0) * 0.5, 3.0) * 2.9;
float rayLen = fbm(rc * 0.17 + 71.0); // длина луча — рваный верхний край
float h = max(0.05, uHeight * (0.4 + 0.95 * rayLen) * (1.0 - depth * 0.2));
float v = (uv.y - ridge) / h; // 0 на кромке, 1 — на верхушках лучей
// асимметрия профиля: вниз обрыв, вверх длинный хвост
float lowW = 0.03 + uSoft * 0.09;
float down = exp(-pow(max(-v, 0.0) / lowW, 2.0));
float up = exp(-max(v, 0.0) * (3.5 - uSoft * 0.9));
float body = mix(up, down, step(v, 0.0));
// самая яркая часть занавеса — тонкая линия по нижней кромке: там поток тормозится
// о плотный воздух. Без неё занавес выглядит одинаково светящимся по всей высоте.
// Разрывы (patch) обязательны: сплошная кромка через весь кадр читается как обводка,
// проведённая инструментом, а не как свет
float patch = 0.12 + 1.25 * fbm(x * 3.2 + t * 0.35 + fi * 7.0);
body *= 1.0 + exp(-pow((v - 0.03) / (0.045 + uSoft * 0.05), 2.0)) * 0.6 * patch * near;
// у самой кромки лучи сливаются в сплошную линию и расходятся только выше — так же,
// как в небе: снизу занавес выглядит светящимся швом, сверху распадается на прутья
float emerge = smoothstep(0.0, 0.28, v);
float streaks = mix(1.0, 0.18 + rayLit, emerge * uRays);
// занавес живёт не по всей длине: где-то он яркий, где-то почти гаснет. Без этой
// медленной волны получается ровная лента через весь кадр — самый нежизненный вариант
float alive = 0.25 + 0.85 * fbm(x * 0.8 + t * 0.22 + fi * 11.0);
// к краям кадра занавес истончается — уходит за горизонт
float ends = smoothstep(0.0, 0.16, uv.x) * smoothstep(1.0, 0.84, uv.x);
float w = body * streaks * alive * near * (0.3 + 0.7 * ends);
// цвет по высоте: −0.3 (под кромкой) → розовый, 0 → базовый тон, 1.4 → фиолет
float g = clamp(v, -0.3, 1.4);
float fringe = smoothstep(0.02, -0.14, v); // 1 только под кромкой
float hue = fract(uHue + uSpread * g * 1.5 + fringe * 0.55 + depth * uSpread * 0.3);
// насыщенность обязательно с потолком: hsv2rgb при s > 1 уводит боковые каналы
// в минус, и такой занавес начинал ВЫЧИТАТЬ свет из соседнего
float sat = min(1.0, 0.68 + 0.24 * smoothstep(-0.1, 1.0, g) + fringe * 0.15);
col += hsv2rgb(vec3(hue, sat, 1.0)) * w * 0.95;
// дымка: ореол БЕЗ лучей — подсвеченный воздух у самой кромки, где занавес плотнее.
// Вклад насыщается (uGlow/(1+uGlow)), поэтому ползунок меняет характер свечения,
// а не общую яркость — за неё отвечает отдельная крутилка
float haloW = h * 0.5 + 0.07;
float halo = exp(-pow((uv.y - ridge - h * 0.18) / haloW, 2.0));
col += hsv2rgb(vec3(fract(uHue + uSpread * 0.35 + depth * uSpread * 0.3), 0.7, 1.0))
* halo * (uGlow / (1.0 + uGlow)) * 0.19 * alive * patch * near * (0.3 + 0.7 * ends);
}
col *= uIntensity;
col *= smoothstep(-0.02, 0.3, uv.y); // к низу кадра сияние гаснет
// тональная компрессия вместо clamp: пересветы сохраняют ЦВЕТ, а не выгорают в белое
// пятно — сложение трёх занавесов раньше давало ровно его
col = vec3(1.0) - exp(-col * 1.35);
float a = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0);
gl_FragColor = vec4(col, a); // premultiplied: свет складывается, а не гасит небо
}
`;
/**
* Северное сияние: занавесы света с вертикальными лучами текут по небу, переливаясь по высоте
* от зелёного к фиолетовому.
*
* Реализация — один полноэкранный фрагментный шейдер на OGL, ни одного полигона сверх
* обязательного треугольника. Занавес задан нижней кромкой (складки) и лучами над ней;
* яркость, длина луча и цвет — производные одной вертикальной координаты внутри занавеса.
*
* Сцена засыпает: кадры считаются только когда холст в зоне видимости и вкладка активна.
* `prefers-reduced-motion` и `data-shot` рисуют один кадр — картинка остаётся, движение нет.
*/
export function AuroraField({
bands = 3,
speed = 0.22,
height = 0.55,
softness = 0.5,
rays = 0.8,
glow = 0.9,
intensity = 1,
hue = 0.36,
spread = 0.2,
backdrop = NIGHT,
}: AuroraFieldProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ bands, speed, height, softness, rays, glow, intensity, hue, spread });
cfg.current = { bands, speed, height, softness, rays, glow, intensity, hue, spread };
useEffect(() => {
const el = host.current;
if (!el) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// premultipliedAlpha: свет занавесов СКЛАДЫВАЕТСЯ с небом. При обычной альфе тусклые
// области умножали фон на (1 - a) и затемняли его — по кадру шла грязная вуаль
const renderer = new Renderer({
alpha: true,
premultipliedAlpha: 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] },
uBands: { value: cfg.current.bands },
uHeight: { value: cfg.current.height },
uSoft: { value: cfg.current.softness },
uRays: { value: cfg.current.rays },
uGlow: { value: cfg.current.glow },
uIntensity: { value: cfg.current.intensity },
uHue: { value: cfg.current.hue },
uSpread: { value: cfg.current.spread },
},
});
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.uBands!.value = Math.round(c.bands);
program.uniforms.uHeight!.value = c.height;
program.uniforms.uSoft!.value = c.softness;
program.uniforms.uRays!.value = c.rays;
program.uniforms.uGlow!.value = c.glow;
program.uniforms.uIntensity!.value = c.intensity;
program.uniforms.uHue!.value = c.hue;
program.uniforms.uSpread!.value = c.spread;
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="aur"
style={{
// заполняет РОДИТЕЛЯ, а не вьюпорт, и не назначает себе z-index: стопкой управляет
// тот, кто ставит запись фоном. Без этого правила фон нельзя переиспользовать в секции
position: "absolute",
inset: 0,
pointerEvents: "none",
...(backdrop === false ? null : { background: backdrop }),
}}
/>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0