← каталог
Magnetic Button
Кнопка-магнит с ГЛУБИНОЙ: корпус тянется к курсору, а подпись внутри едет дальше него отдельным слоем — двухслойный параллакс читается как объём. Упругий возврат GSAP, textStrength 0 даёт прежний плоский вариант.
gsapmouse-movehovermagneticspringintermediate~24 КБreduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| strength? | number | 0.4 | Сила притяжения кнопки к курсору, 0..1 (по умолчанию 0.4). |
| textStrength? | number | 0.35 | Дополнительный ход ПОДПИСИ внутри кнопки, 0..1 (по умолчанию 0.35). Текст едет чуть дальше корпуса — появляется глубина, как будто подпись лежит на отдельном слое. 0 — плоский вариант, корпус и текст слитны. |
| children | ReactNode | — |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/magnetic-button.jsonзависимости: gsap@^3.15.0 · @gsap/react@^2.1.2
Что нужно в проекте до установки
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 { 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>
);
}
import gsap from "gsap";
/**
* Ванильная версия: магнитит переданный элемент к курсору.
* Возвращает функцию отписки. Уважает `prefers-reduced-motion`.
*
* const dispose = magnetic(document.querySelector("button")!, 0.4);
*/
export function magnetic(el: HTMLElement, strength = 0.4): () => void {
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 move = (e: MouseEvent) => {
const { left, top, width, height } = el.getBoundingClientRect();
xTo((e.clientX - (left + width / 2)) * strength);
yTo((e.clientY - (top + height / 2)) * strength);
};
const leave = () => {
xTo(0);
yTo(0);
};
el.addEventListener("mousemove", move);
el.addEventListener("mouseleave", leave);
return () => {
el.removeEventListener("mousemove", move);
el.removeEventListener("mouseleave", leave);
};
}
.mb-btn {
padding: 1rem 2.25rem;
border-radius: 999px;
border: none;
cursor: pointer;
font: inherit;
font-weight: 600;
font-size: 1.05rem;
color: #fff;
background: linear-gradient(100deg, oklch(0.62 0.19 280), oklch(0.72 0.15 220));
box-shadow: 0 8px 30px -8px oklch(0.62 0.19 280 / 0.6);
will-change: transform;
}
.mb-btn:focus-visible {
outline: 2px solid oklch(0.72 0.15 220);
outline-offset: 3px;
}
/* Варианты витрины: глубина одна и та же, меняется только одежда. */
.mb-btn--ghost {
background: transparent;
border: 1px solid oklch(0.4 0.03 275);
color: oklch(0.92 0.01 265);
box-shadow: none;
}
.mb-btn--ghost:hover {
border-color: oklch(0.72 0.16 300);
color: oklch(0.85 0.08 300);
}
.mb-btn--dark {
background: oklch(0.22 0.02 275);
border: 1px solid oklch(0.34 0.02 275);
color: oklch(0.95 0.01 265);
box-shadow: 0 10px 34px -12px oklch(0 0 0 / 0.7);
}
import gsap from "gsap";
/**
* Magnetic Button — vanilla JS/GSAP (без React).
*
* <button id="btn">Наведи меня</button>
* <script type="module">
* import { initMagneticButton } from './magnetic-button.vanilla.js';
* const destroy = initMagneticButton(document.querySelector('#btn'), {
* strength: 0.4,
* textStrength: 0.35, // подпись едет дальше корпуса — глубина; 0 — плоский вариант
* });
* </script>
*
* При textStrength > 0 содержимое кнопки оборачивается в span-слой (и разворачивается
* обратно в destroy): корпус и подпись едут к одной цели на разную долю расстояния —
* двухслойный параллакс читается как объём.
*
* @param {HTMLElement} el — любая существующая кнопка/ссылка
* @param {{ strength?: number, textStrength?: number }} [options]
* @returns {() => void} destroy
*/
export function initMagneticButton(el, { strength = 0.4, textStrength = 0.35 } = {}) {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return () => {};
// слой подписи: собираем содержимое в span, чтобы двигать его отдельно от корпуса
let label = null;
if (textStrength > 0) {
label = document.createElement("span");
label.style.display = "inline-block";
label.style.willChange = "transform";
while (el.firstChild) label.appendChild(el.firstChild);
el.appendChild(label);
}
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) => {
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);
if (lxTo) lxTo(dx * textStrength);
if (lyTo) lyTo(dy * textStrength);
};
const leave = () => {
xTo(0);
yTo(0);
if (lxTo) lxTo(0);
if (lyTo) lyTo(0);
};
el.addEventListener("mousemove", move);
el.addEventListener("mouseleave", leave);
return () => {
el.removeEventListener("mousemove", move);
el.removeEventListener("mouseleave", leave);
gsap.set(el, { x: 0, y: 0 });
if (label) {
// вернуть DOM, каким он был: init живёт на чужой разметке
while (label.firstChild) el.insertBefore(label.firstChild, label);
label.remove();
}
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0