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

Blend Cursor

Кастомный курсор-круг с mix-blend-mode:difference: инвертирует всё под собой, над интерактивным текстом растёт.

cssmouse-movehoverbasic0reduced-motion ✓

Установка

npx shadcn@latest add https://motiva.pages.dev/r/blend-cursor.json
Что нужно в проекте до установки
  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";

/**
 * Кастомный курсор с 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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0