← каталог
Blend Cursor
Кастомный курсор-круг с mix-blend-mode:difference: инвертирует всё под собой, над интерактивным текстом растёт.
cssmouse-movehoverbasic0reduced-motion ✓
Установка
npx shadcn@latest add https://motiva.pages.dev/r/blend-cursor.jsonЧто нужно в проекте до установки
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";
/**
* Кастомный курсор с mix-blend-mode:difference — круг инвертирует всё под собой,
* над текстом растёт. Позиция — прямой transform, сглаживание — CSS transition.
* Уважает reduced-motion / data-shot (без кастомного курсора).
*/
export function BlendCursor() {
const wrap = useRef<HTMLDivElement>(null);
const dot = useRef<HTMLDivElement>(null);
useEffect(() => {
const box = wrap.current;
const el = dot.current;
if (!box || !el) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return;
const onMove = (e: PointerEvent) => {
const r = box.getBoundingClientRect();
el.style.opacity = "1";
el.style.transform = `translate(${e.clientX - r.left}px, ${e.clientY - r.top}px) translate(-50%, -50%) scale(${
(e.target as HTMLElement).closest("[data-cursor-grow]") ? 2.6 : 1
})`;
};
const onLeave = () => {
el.style.opacity = "0";
};
box.addEventListener("pointermove", onMove);
box.addEventListener("pointerleave", onLeave);
return () => {
box.removeEventListener("pointermove", onMove);
box.removeEventListener("pointerleave", onLeave);
};
}, []);
return (
<div ref={wrap} style={{ position: "absolute", inset: 0, overflow: "hidden", cursor: "none", background: "oklch(0.15 0.015 270)" }}>
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
color: "oklch(0.92 0.01 265)",
textAlign: "center",
}}
>
<div>
<h3 data-cursor-grow style={{ margin: 0, fontSize: "clamp(1.8rem, 1rem + 3.4vw, 3rem)", fontWeight: 800, letterSpacing: "-0.02em" }}>
Курсор-инверсия
</h3>
<p style={{ margin: "10px 0 0", fontFamily: "ui-monospace, monospace", fontSize: "0.78rem", opacity: 0.6 }}>
круг инвертирует цвета, над заголовком — растёт
</p>
</div>
</div>
<div
ref={dot}
aria-hidden
style={{
position: "absolute",
left: 0,
top: 0,
width: 34,
height: 34,
borderRadius: "50%",
background: "#fff",
mixBlendMode: "difference",
pointerEvents: "none",
opacity: 0,
transition: "transform 0.16s ease-out, opacity 0.25s",
zIndex: 5,
}}
/>
</div>
);
}
/**
* Blend Cursor — vanilla JS, ноль зависимостей. Круг с mix-blend-mode:difference
* инвертирует всё под собой; над элементами с [data-cursor-grow] растёт.
*
* <section id="zone">…контент… <h1 data-cursor-grow>Заголовок</h1></section>
* <script type="module">
* import { initBlendCursor } from './blend-cursor.vanilla.js';
* const destroy = initBlendCursor(document.querySelector('#zone'), { size: 34, grow: 2.6 });
* </script>
*
* @param {HTMLElement} zone — зона действия (курсор скрывается внутри неё)
* @param {{ size?: number, grow?: number }} [options]
* @returns {() => void} destroy
*/
export function initBlendCursor(zone, { size = 34, grow = 2.6 } = {}) {
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
window.matchMedia("(hover: none)").matches;
if (frozen) return () => {};
const dot = document.createElement("div");
dot.style.cssText = `position:fixed;left:0;top:0;width:${size}px;height:${size}px;border-radius:50%;
background:#fff;mix-blend-mode:difference;pointer-events:none;opacity:0;z-index:9999;
transition:transform 0.16s ease-out, opacity 0.25s;`;
document.body.appendChild(dot);
const prevCursor = zone.style.cursor;
zone.style.cursor = "none";
const onMove = (e) => {
dot.style.opacity = "1";
const scale = e.target.closest?.("[data-cursor-grow]") ? grow : 1;
dot.style.transform = `translate(${e.clientX}px, ${e.clientY}px) translate(-50%, -50%) scale(${scale})`;
};
const onLeave = () => {
dot.style.opacity = "0";
};
zone.addEventListener("pointermove", onMove);
zone.addEventListener("pointerleave", onLeave);
return () => {
zone.removeEventListener("pointermove", onMove);
zone.removeEventListener("pointerleave", onLeave);
zone.style.cursor = prevCursor;
dot.remove();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0