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

Magnetic Button

Кнопка-магнит с ГЛУБИНОЙ: корпус тянется к курсору, а подпись внутри едет дальше него отдельным слоем — двухслойный параллакс читается как объём. Упругий возврат GSAP, textStrength 0 даёт прежний плоский вариант.

gsapmouse-movehovermagneticspringintermediate~24 КБreduced-motion ✓

Пропсы

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

ПропТипПо умолчаниюЧто это
strength?number0.4Сила притяжения кнопки к курсору, 0..1 (по умолчанию 0.4).
textStrength?number0.35Дополнительный ход ПОДПИСИ внутри кнопки, 0..1 (по умолчанию 0.35). Текст едет чуть дальше корпуса — появляется глубина, как будто подпись лежит на отдельном слое. 0 — плоский вариант, корпус и текст слитны.
childrenReactNode

Установка

npx shadcn@latest add https://motiva.pages.dev/r/magnetic-button.json

зависимости: gsap@^3.15.0 · @gsap/react@^2.1.2

Что нужно в проекте до установки
  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 { useRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";

export interface MagneticButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  /** Сила притяжения кнопки к курсору, 0..1 (по умолчанию 0.4). */
  strength?: number;
  /**
   * Дополнительный ход ПОДПИСИ внутри кнопки, 0..1 (по умолчанию 0.35).
   * Текст едет чуть дальше корпуса — появляется глубина, как будто подпись лежит
   * на отдельном слое. 0 — плоский вариант, корпус и текст слитны.
   */
  textStrength?: number;
  children: ReactNode;
}

/**
 * Кнопка, которую магнитит к курсору внутри её области, с упругим возвратом.
 *
 * Глубину даёт ВТОРОЙ слой: подпись обёрнута в span и получает собственный твин
 * с добавочной силой `textStrength`. Корпус и текст едут к одной цели, но на разную
 * долю расстояния — параллакс в два слоя читается как объём, а не как сдвиг.
 * Уважает `prefers-reduced-motion` (оба слоя неподвижны).
 */
export function MagneticButton({ strength = 0.4, textStrength = 0.35, children, ...props }: MagneticButtonProps) {
  const ref = useRef<HTMLButtonElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);

  useGSAP(
    () => {
      const el = ref.current;
      const label = labelRef.current;
      if (!el) return;
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

      const xTo = gsap.quickTo(el, "x", { duration: 1, ease: "elastic.out(1, 0.3)" });
      const yTo = gsap.quickTo(el, "y", { duration: 1, ease: "elastic.out(1, 0.3)" });
      // подпись догоняет чуть дольше — слои расходятся в движении и сходятся в покое
      const lxTo = label ? gsap.quickTo(label, "x", { duration: 1.1, ease: "elastic.out(1, 0.32)" }) : null;
      const lyTo = label ? gsap.quickTo(label, "y", { duration: 1.1, ease: "elastic.out(1, 0.32)" }) : null;

      const move = (e: MouseEvent) => {
        const { left, top, width, height } = el.getBoundingClientRect();
        const dx = e.clientX - (left + width / 2);
        const dy = e.clientY - (top + height / 2);
        xTo(dx * strength);
        yTo(dy * strength);
        lxTo?.(dx * textStrength);
        lyTo?.(dy * textStrength);
      };
      const leave = () => {
        xTo(0);
        yTo(0);
        lxTo?.(0);
        lyTo?.(0);
      };

      el.addEventListener("mousemove", move);
      el.addEventListener("mouseleave", leave);
      return () => {
        el.removeEventListener("mousemove", move);
        el.removeEventListener("mouseleave", leave);
      };
    },
    { scope: ref, dependencies: [strength, textStrength] },
  );

  return (
    <button ref={ref} {...props}>
      {/* inline-block: transform на инлайновом элементе не работает */}
      <span ref={labelRef} style={{ display: "inline-block", willChange: "transform" }}>
        {children}
      </span>
    </button>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0