← каталог
Magnetic Menu
Меню с магнитными пунктами: ближний к курсору притягивается и упруго возвращается; подчёркивание въезжает на ховере.
gsapmouse-movehovermagneticspringintermediate~23 КБreduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | string[] | ITEMS |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/magnetic-menu.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 PointerEvent } from "react";
import gsap from "gsap";
const ITEMS = ["Главная", "Работы", "Студия", "Контакты"];
/**
* Меню с магнитными пунктами: ближний к курсору пункт притягивается (gsap quickTo),
* при уходе — упруго возвращается. Уважает reduced-motion / data-shot (статичное меню).
*/
export function MagneticMenu({ items = ITEMS }: { items?: string[] }) {
const root = useRef<HTMLElement>(null);
const movers = useRef<{ x: gsap.QuickToFunc; y: gsap.QuickToFunc }[]>([]);
useEffect(() => {
const el = root.current;
if (!el) return;
movers.current = [...el.querySelectorAll<HTMLElement>(".mm-item")].map((item) => ({
x: gsap.quickTo(item, "x", { duration: 0.4, ease: "power3.out" }),
y: gsap.quickTo(item, "y", { duration: 0.4, ease: "power3.out" }),
}));
}, [items]);
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const onMove = (e: PointerEvent<HTMLElement>) => {
const el = root.current;
if (!el || frozen()) return;
el.querySelectorAll<HTMLElement>(".mm-item").forEach((item, i) => {
const r = item.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
const dist = Math.hypot(dx, dy);
const radius = 110;
const pull = Math.max(0, 1 - dist / radius) * 0.45;
movers.current[i]?.x(dx * pull);
movers.current[i]?.y(dy * pull);
});
};
const reset = () => {
const el = root.current;
if (!el) return;
el.querySelectorAll<HTMLElement>(".mm-item").forEach((item) => {
gsap.to(item, { x: 0, y: 0, duration: 0.7, ease: "elastic.out(1, 0.4)" });
});
};
return (
<nav
ref={root}
onPointerMove={onMove}
onPointerLeave={reset}
aria-label="Магнитное меню"
style={{ display: "flex", gap: "2.6rem", padding: "2rem 2.4rem" }}
>
<style>{`
.mm-item { position: relative; background: none; border: none; padding: 6px 2px; cursor: pointer;
color: oklch(0.8 0.02 265); font-size: 1.05rem; font-weight: 600; letter-spacing: -0.01em;
transition: color 0.25s; will-change: transform; }
.mm-item::after { content: ""; position: absolute; left: 0; right: 100%; bottom: 0; height: 2px;
background: oklch(0.72 0.15 300); transition: right 0.3s cubic-bezier(0.16,1,0.3,1); }
.mm-item:hover { color: oklch(0.96 0.01 265); }
.mm-item:hover::after { right: 0; }
`}</style>
{items.map((label) => (
<button key={label} className="mm-item">
{label}
</button>
))}
</nav>
);
}
import gsap from "gsap";
/**
* Magnetic Menu — vanilla JS/GSAP (без React). Пункты существующего меню притягиваются
* к курсору (с затуханием по расстоянию) и упруго возвращаются при уходе.
*
* <nav id="menu"><a>Главная</a><a>Работы</a><a>Контакты</a></nav>
* <script type="module">
* import { initMagneticMenu } from './magnetic-menu.vanilla.js';
* const destroy = initMagneticMenu(document.querySelector('#menu'), { radius: 110, pull: 0.45 });
* </script>
*
* Оформление — на твоей стороне (init отвечает только за поведение). Вид как в демо:
*
* #menu { display: flex; gap: 2.6rem; padding: 2rem 2.4rem; }
* #menu > * { position: relative; padding: 6px 2px; cursor: pointer; transition: color .25s;
* color: oklch(0.8 0.02 265); font: 600 1.05rem/1 system-ui; will-change: transform; }
* #menu > *::after { content: ""; position: absolute; left: 0; right: 100%; bottom: 0; height: 2px;
* background: oklch(0.72 0.15 300); transition: right .3s cubic-bezier(.16,1,.3,1); }
* #menu > *:hover { color: oklch(0.96 0.01 265); }
* #menu > *:hover::after { right: 0; }
*
* @param {HTMLElement} nav — контейнер меню; магнитятся его прямые дети (или itemSelector)
* @param {{ itemSelector?: string, radius?: number, pull?: number }} [options]
* @returns {() => void} destroy
*/
export function initMagneticMenu(nav, { itemSelector = ":scope > *", radius = 110, pull = 0.45 } = {}) {
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const items = [...nav.querySelectorAll(itemSelector)];
const movers = items.map((item) => ({
x: gsap.quickTo(item, "x", { duration: 0.4, ease: "power3.out" }),
y: gsap.quickTo(item, "y", { duration: 0.4, ease: "power3.out" }),
}));
const onMove = (e) => {
if (frozen()) return;
items.forEach((item, i) => {
const r = item.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
const strength = Math.max(0, 1 - Math.hypot(dx, dy) / radius) * pull;
movers[i].x(dx * strength);
movers[i].y(dy * strength);
});
};
const reset = () =>
items.forEach((item) => gsap.to(item, { x: 0, y: 0, duration: 0.7, ease: "elastic.out(1, 0.4)" }));
nav.addEventListener("pointermove", onMove);
nav.addEventListener("pointerleave", reset);
return () => {
nav.removeEventListener("pointermove", onMove);
nav.removeEventListener("pointerleave", reset);
items.forEach((item) => gsap.set(item, { x: 0, y: 0 }));
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0