← каталог
Cursor Ghost
Упругий хвост за курсором: цепочка догоняющих друг друга узлов. Форму рисует обычный 2D-холст (круги + blur сливают их в каплю), а цвет, кайму и разброс каналов по кромке считает шейдер — каждый делает то, что у него дёшево. Отличие от cursor-trail: там независимые частицы, здесь связанное тело.
oglmouse-movespringadvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| nodes? | number | 22 | Длина хвоста в узлах (формат «22»). |
| radius? | number | 26 | Радиус головы, px (формат «26»). |
| follow? | number | 0.34 | Насколько узел догоняет предыдущий за кадр, 0–1: меньше — вязче (формат «0.34»). |
| hue? | number | 0.78 | Тон в круге, 0–1 (формат «0.78»). |
| fringe? | number | 3 | Разброс каналов по краю, px (формат «3»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/cursor-ghost.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, Texture, Triangle } from "ogl";
export interface CursorGhostProps {
/** Длина хвоста в узлах (формат «22»). */
nodes?: number;
/** Радиус головы, px (формат «26»). */
radius?: number;
/** Насколько узел догоняет предыдущий за кадр, 0–1: меньше — вязче (формат «0.34»). */
follow?: number;
/** Тон в круге, 0–1 (формат «0.78»). */
hue?: number;
/** Разброс каналов по краю, px (формат «3»). */
fringe?: 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); }
`;
/**
* Форма приходит с процессора одной картинкой, а цветом и краем занимается видеокарта.
* Каналы берутся из трёх слегка разных точек маски — оттого по кромке идёт цветная кайма,
* которой на самом холсте нет и быть не может.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform sampler2D tMask;
uniform vec2 uRes;
uniform float uHue, uFringe;
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);
}
void main() {
vec2 px = uFringe / uRes;
float r = texture2D(tMask, vUv + vec2(px.x, 0.0)).r;
float g = texture2D(tMask, vUv).r;
float b = texture2D(tMask, vUv - vec2(px.x, 0.0)).r;
float m = max(max(r, g), b);
if (m < 0.06) discard;
// Пороги берутся ВЫСОКО и с широким переходом. Низкий порог включает в тело весь
// хвост размытия, и вместо капли получается плоская плита с резкой каймой по краю —
// тем заметнее, что маска вчетверо мельче кадра и её ступеньки видны на растяжении.
float body = smoothstep(0.14, 0.7, m);
float core = smoothstep(0.78, 0.99, g);
vec3 tint = hsv2rgb(vec3(uHue, 0.72, 1.0));
vec3 edge = hsv2rgb(vec3(fract(uHue + 0.1), 0.9, 1.0));
vec3 col = mix(edge, tint, body);
col = mix(col, vec3(1.0), core * 0.55);
// каналы взяты из трёх разных точек — по кромке идёт развод, которого в маске нет
col *= vec3(0.8 + r * 0.5, 0.8 + g * 0.5, 0.8 + b * 0.5);
gl_FragColor = vec4(col, body * 0.72);
}
`;
/**
* Упругий хвост за курсором: цепочка догоняющих друг друга узлов.
*
* Запись про РАЗДЕЛЕНИЕ РАБОТЫ между холстом и шейдером, и каждый делает то, что у него
* дёшево.
*
* Форму рисует обычный 2D-холст: два десятка кругов убывающего радиуса плюс `filter: blur`,
* который сливает их в одну гладкую каплю. Повторить такое слияние в шейдере значит считать
* поле расстояний до двадцати двух точек в каждом пикселе; здесь это одна встроенная
* операция браузера. Холст при этом рисуется вчетверо мельче кадра — маска мягкая,
* разрешение ей ни к чему.
*
* Дальше холст уходит на видеокарту ТЕКСТУРОЙ и служит маской. Цвет, кайму и мягкий край
* считает шейдер — и делает то, чего 2D-холст дёшево не умеет: берёт три канала из трёх
* слегка разных точек маски. По кромке появляется цветной развод, которого в самой маске
* нет ни в каком виде.
*
* Упругость — не кривая анимации, а физика цепочки: каждый узел за кадр проходит долю
* пути до предыдущего. Хвост поэтому сам растягивается на рывке и собирается в покое,
* без единого твина и без единой длительности.
*
* Отличие от `cursor-trail`: там независимые частицы, у каждой своя жизнь; здесь СВЯЗАННОЕ
* тело, у которого форма целиком следует из положения головы.
*
* Цикл кадров засыпает, когда цепочка догнала курсор: сравнивается пройденный за кадр путь,
* а не время. На грубом указателе и при `prefers-reduced-motion` запись не рисует ничего.
*/
export function CursorGhost({
nodes = 22,
radius = 26,
follow = 0.34,
hue = 0.78,
fringe = 3,
}: CursorGhostProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ nodes, radius, follow, hue, fringe });
cfg.current = { nodes, radius, follow, hue, fringe };
useEffect(() => {
const el = host.current;
if (!el) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
window.matchMedia("(pointer: coarse)").matches;
if (frozen) return; // курсора нет или движение нежелательно — не рисуем ничего
const dpr = Math.min(2, window.devicePixelRatio || 1);
const renderer = new Renderer({ alpha: true, antialias: false, dpr });
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 SCALE = 0.25;
// Холста ДВА, и это не расточительство. `ctx.filter` в canvas 2D применяется к каждой
// операции рисования по отдельности: двадцать два размытых круга складываются
// полупрозрачными кромками, союз разбухает далеко за свои границы, и вместо капли
// получается плита втрое шире хвоста. Поэтому сначала собираем СИЛУЭТ без фильтра,
// а потом размываем его один раз целиком.
const raw = document.createElement("canvas");
const rawCtx = raw.getContext("2d");
const mask = document.createElement("canvas");
const ctx = mask.getContext("2d");
const tex = new Texture(gl, {
image: mask,
generateMipmaps: false,
minFilter: gl.LINEAR,
magFilter: gl.LINEAR,
wrapS: gl.CLAMP_TO_EDGE,
wrapT: gl.CLAMP_TO_EDGE,
});
const program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
transparent: true,
depthTest: false,
uniforms: {
tMask: { value: tex },
uRes: { value: [1, 1] },
uHue: { value: cfg.current.hue },
uFringe: { value: cfg.current.fringe },
},
});
const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });
let chain: { x: number; y: number }[] = [];
let tx = -999;
let ty = -999;
const resize = () => {
const w = el.clientWidth;
const h = el.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h);
program.uniforms.uRes!.value = [w, h];
mask.width = raw.width = Math.max(2, Math.round(w * SCALE));
mask.height = raw.height = Math.max(2, Math.round(h * SCALE));
};
const paint = () => {
if (!ctx || !rawCtx) return;
const c = cfg.current;
// 1. силуэт: круги убывающего радиуса, без фильтра — союз, а не сумма кромок
rawCtx.clearRect(0, 0, raw.width, raw.height);
rawCtx.fillStyle = "#fff";
for (let i = 0; i < chain.length; i++) {
const t = 1 - i / chain.length;
// сужение хвоста быстрее линейного: при мягком показателе тело остаётся
// толстым больше половины длины и читается как лента, а не как хвост
const r = c.radius * SCALE * Math.pow(t, 1.4);
if (r < 0.4) continue;
rawCtx.beginPath();
rawCtx.arc(chain[i]!.x * SCALE, chain[i]!.y * SCALE, r, 0, Math.PI * 2);
rawCtx.fill();
}
// 2. одно размытие на весь силуэт: в шейдере то же самое стоило бы поля расстояний
// до всех узлов в каждом пикселе
ctx.clearRect(0, 0, mask.width, mask.height);
ctx.filter = `blur(${Math.max(1.2, c.radius * SCALE * 0.24)}px)`;
ctx.drawImage(raw, 0, 0);
ctx.filter = "none";
tex.image = mask;
tex.needsUpdate = true;
};
let raf = 0;
const step = () => {
const c = cfg.current;
const n = Math.max(2, Math.round(c.nodes));
if (chain.length !== n) {
chain = Array.from({ length: n }, () => ({ x: tx, y: ty }));
}
const k = Math.min(0.9, Math.max(0.05, c.follow));
let moved = 0;
chain[0]!.x += (tx - chain[0]!.x) * 0.42;
chain[0]!.y += (ty - chain[0]!.y) * 0.42;
for (let i = 1; i < chain.length; i++) {
const dx = chain[i - 1]!.x - chain[i]!.x;
const dy = chain[i - 1]!.y - chain[i]!.y;
chain[i]!.x += dx * k;
chain[i]!.y += dy * k;
moved += Math.abs(dx * k) + Math.abs(dy * k);
}
moved += Math.abs(tx - chain[0]!.x) + Math.abs(ty - chain[0]!.y);
program.uniforms.uHue!.value = c.hue;
program.uniforms.uFringe!.value = c.fringe;
paint();
renderer.render({ scene: mesh });
return moved;
};
const loop = () => {
// засыпаем по ПУТИ, а не по времени: сколько цепочке доезжать — её дело
raf = step() > 0.35 ? requestAnimationFrame(loop) : 0;
};
const wake = () => {
if (!raf) raf = requestAnimationFrame(loop);
};
const onMove = (e: PointerEvent) => {
const r = el.getBoundingClientRect();
tx = e.clientX - r.left;
ty = e.clientY - r.top;
if (chain.length && chain[0]!.x < -900) chain = chain.map(() => ({ x: tx, y: ty }));
wake();
};
window.addEventListener("pointermove", onMove, { passive: true });
resize();
const ro = new ResizeObserver(resize);
ro.observe(el);
const onVis = () => {
if (document.hidden) {
cancelAnimationFrame(raf);
raf = 0;
}
};
document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
window.removeEventListener("pointermove", onMove);
document.removeEventListener("visibilitychange", onVis);
gl.getExtension("WEBGL_lose_context")?.loseContext();
gl.canvas.remove();
};
}, []);
return (
<div
ref={host}
className="cgh"
aria-hidden="true"
style={{ position: "fixed", inset: 0, zIndex: 60, pointerEvents: "none" }}
/>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — https://codepen.io/ksenia-k/pen/yLGxEKb (лицензия не указана, код не заимствован)v1.0.0