Motiva
Мои коллекции
← каталог

Cursor Ghost

Упругий хвост за курсором: цепочка догоняющих друг друга узлов. Форму рисует обычный 2D-холст (круги + blur сливают их в каплю), а цвет, кайму и разброс каналов по кромке считает шейдер — каждый делает то, что у него дёшево. Отличие от cursor-trail: там независимые частицы, здесь связанное тело.

oglmouse-movespringadvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
nodes?number22Длина хвоста в узлах (формат «22»).
radius?number26Радиус головы, px (формат «26»).
follow?number0.34Насколько узел догоняет предыдущий за кадр, 0–1: меньше — вязче (формат «0.34»).
hue?number0.78Тон в круге, 0–1 (формат «0.78»).
fringe?number3Разброс каналов по краю, px (формат «3»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/cursor-ghost.json

зависимости: ogl

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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