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

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

Что нужно в проекте до установки
  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, 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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0