← каталог
Cursor Follow
Кастомный курсор: кольцо плавно следует за мышью, точка — точно за ней; кольцо растёт над интерактивными элементами.
gsapmouse-movehovermagneticbasic~22 КБreduced-motion ✓
Установка
npx shadcn@latest add https://motiva.pages.dev/r/cursor-follow.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
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, type CSSProperties } from "react";
import gsap from "gsap";
/**
* Кастомный курсор: кольцо следует за мышью с плавным запаздыванием, точка — точно за ней.
* Кольцо растёт над элементами с `data-cursor`. Скрывается при reduced-motion / data-shot / тач.
* Смонтируй один раз на страницу (position: fixed, слушает document).
*/
export function CursorFollow() {
const ring = useRef<HTMLDivElement>(null);
const dot = useRef<HTMLDivElement>(null);
useEffect(() => {
const r = ring.current;
const d = dot.current;
if (!r || !d) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
window.matchMedia("(hover: none)").matches;
if (frozen) {
r.style.display = "none";
d.style.display = "none";
return;
}
const prevCursor = document.body.style.cursor;
document.body.style.cursor = "none";
const xr = gsap.quickTo(r, "x", { duration: 0.5, ease: "power3" });
const yr = gsap.quickTo(r, "y", { duration: 0.5, ease: "power3" });
const xd = gsap.quickTo(d, "x", { duration: 0.12 });
const yd = gsap.quickTo(d, "y", { duration: 0.12 });
const move = (e: MouseEvent) => {
xr(e.clientX);
yr(e.clientY);
xd(e.clientX);
yd(e.clientY);
};
const over = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest?.("[data-cursor]")) gsap.to(r, { scale: 2.2, duration: 0.3 });
};
const out = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest?.("[data-cursor]")) gsap.to(r, { scale: 1, duration: 0.3 });
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseover", over);
document.addEventListener("mouseout", out);
return () => {
document.body.style.cursor = prevCursor;
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseover", over);
document.removeEventListener("mouseout", out);
};
}, []);
const base: CSSProperties = {
position: "fixed",
top: 0,
left: 0,
pointerEvents: "none",
zIndex: 9999,
borderRadius: "50%",
};
return (
<>
<div ref={ring} style={{ ...base, width: 34, height: 34, margin: "-17px 0 0 -17px", border: "1.5px solid oklch(0.72 0.15 220)" }} />
<div ref={dot} style={{ ...base, width: 6, height: 6, margin: "-3px 0 0 -3px", background: "oklch(0.72 0.15 220)" }} />
</>
);
}
import gsap from "gsap";
/**
* Cursor Follow — vanilla JS/GSAP (без React). Кольцо с запаздыванием + точка;
* кольцо растёт над элементами с `data-cursor`. Создаёт свои элементы сам.
*
* <script type="module">
* import { initCursorFollow } from './cursor-follow.vanilla.js';
* const destroy = initCursorFollow({ color: 'oklch(0.72 0.15 220)' });
* </script>
*
* @param {{ color?: string }} [options]
* @returns {() => void} destroy
*/
export function initCursorFollow({ color = "oklch(0.72 0.15 220)" } = {}) {
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
window.matchMedia("(hover: none)").matches;
if (frozen) return () => {};
const base = `position:fixed;top:0;left:0;pointer-events:none;z-index:9999;border-radius:50%;`;
const ring = document.createElement("div");
ring.style.cssText = `${base}width:34px;height:34px;margin:-17px 0 0 -17px;border:1.5px solid ${color};`;
const dot = document.createElement("div");
dot.style.cssText = `${base}width:6px;height:6px;margin:-3px 0 0 -3px;background:${color};`;
document.body.append(ring, dot);
const prevCursor = document.body.style.cursor;
document.body.style.cursor = "none";
const xr = gsap.quickTo(ring, "x", { duration: 0.5, ease: "power3" });
const yr = gsap.quickTo(ring, "y", { duration: 0.5, ease: "power3" });
const xd = gsap.quickTo(dot, "x", { duration: 0.12 });
const yd = gsap.quickTo(dot, "y", { duration: 0.12 });
const move = (e) => {
xr(e.clientX);
yr(e.clientY);
xd(e.clientX);
yd(e.clientY);
};
const over = (e) => {
if (e.target.closest?.("[data-cursor]")) gsap.to(ring, { scale: 2.2, duration: 0.3 });
};
const out = (e) => {
if (e.target.closest?.("[data-cursor]")) gsap.to(ring, { scale: 1, duration: 0.3 });
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseover", over);
document.addEventListener("mouseout", out);
return () => {
document.body.style.cursor = prevCursor;
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseover", over);
document.removeEventListener("mouseout", out);
ring.remove();
dot.remove();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0