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

Magnet Lines

Поле штрихов смотрит на курсор: угол каждого считает сам CSS через atan2(), скрипт шлёт только координату курсора в единицах клеток. В кадре ни строчки JS, rAF-цикла нет вовсе.

cssmouse-movemagneticintermediate0reduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
columns?number9Столбцов в поле.
rows?number9Строк в поле.
cellSize?number44Предельный размер клетки, px: на узком экране поле сжимается само.
reach?number5Радиус влияния В КЛЕТКАХ: дальше него штрих уходит в базовую яркость.
ease?number0.45Секунд на догон курсора: 0 — штрихи прилипают к нему мгновенно.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/magnet-lines.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";
import type { CSSProperties } from "react";

export interface MagnetLinesProps {
  /** Столбцов в поле. */
  columns?: number;
  /** Строк в поле. */
  rows?: number;
  /** Предельный размер клетки, px: на узком экране поле сжимается само. */
  cellSize?: number;
  /** Радиус влияния В КЛЕТКАХ: дальше него штрих уходит в базовую яркость. */
  reach?: number;
  /** Секунд на догон курсора: 0 — штрихи прилипают к нему мгновенно. */
  ease?: number;
}

/**
 * Поле штрихов, каждый смотрит на курсор.
 *
 * Приём записи в том, ЧТО считает скрипт: он не трогает ни один штрих и не знает про
 * углы. Обработчик пишет ровно два числа — положение курсора в единицах КЛЕТОК, — а угол
 * каждого штриха выводит сам CSS через atan2(), яркость и длину — через hypot().
 * Отсюда три следствия: в кадре нет ни строчки скрипта (81 поворот считает движок
 * стилей), нет rAF-цикла вовсе, и рост сетки ничего не стоит обработчику.
 *
 * Плавность взята не переходом угла, а переходом САМОЙ ЦЕЛИ (--ml-x/--ml-y
 * зарегистрированы через @property, поэтому интерполируются). Так снят классический
 * артефакт приёма: анимация угла, проходящего через полуоборот, крутанула бы штрих
 * в обратную сторону на 358 градусов, а здесь плавно едет точка, за которой все штрихи
 * следят честно и кратчайшим путём.
 *
 * Без курсора (и в reduced-motion / data-shot, где слушателей нет вовсе) поле смотрит
 * в свой центр: база = осмысленный радиальный узор, а не куча горизонтальных палок.
 *
 * @example
 * <MagnetLines columns={9} rows={9} reach={5} />
 */
export function MagnetLines({
  columns = 9,
  rows = 9,
  cellSize = 44,
  reach = 5,
  ease = 0.45,
}: MagnetLinesProps = {}) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const field = ref.current;
    if (!field) return;
    const frozen =
      document.documentElement.dataset.shot !== undefined ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
      !window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    if (frozen) return;

    // рамку снимаем заранее и держим свежей наблюдателем: в обработчике указателя
    // должна остаться только арифметика, иначе каждое движение мыши — чтение раскладки
    let rect = field.getBoundingClientRect();
    const measure = () => {
      rect = field.getBoundingClientRect();
    };
    const ro = new ResizeObserver(measure);
    ro.observe(field);

    const onMove = (e: PointerEvent) => {
      const cw = rect.width / columns;
      const ch = rect.height / rows;
      if (!cw || !ch) return;
      field.style.setProperty("--ml-x", String((e.clientX - rect.left) / cw));
      field.style.setProperty("--ml-y", String((e.clientY - rect.top) / ch));
    };
    const home = () => {
      field.style.setProperty("--ml-x", String(columns / 2));
      field.style.setProperty("--ml-y", String(rows / 2));
    };

    window.addEventListener("pointermove", onMove, { passive: true });
    window.addEventListener("scroll", measure, { passive: true });
    document.addEventListener("pointerleave", home);
    return () => {
      ro.disconnect();
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("scroll", measure);
      document.removeEventListener("pointerleave", home);
    };
  }, [columns, rows]);

  const cells = Array.from({ length: columns * rows }, (_, i) => i);

  return (
    <div
      ref={ref}
      className="ml-field"
      style={
        {
          "--ml-cols": columns,
          "--ml-rows": rows,
          "--ml-cell": `${cellSize}px`,
          "--ml-reach": reach,
          "--ml-ease": `${ease}s`,
          // база: поле смотрит в собственный центр, курсор просто переопределяет цель
          "--ml-x": columns / 2,
          "--ml-y": rows / 2,
        } as CSSProperties
      }
    >
      <style>{`
        /* регистрация нужна дважды: незарегистрированное свойство не интерполируется
           переходом и не вычисляется на месте объявления */
        @property --ml-x { syntax: "<number>"; initial-value: 0; inherits: true; }
        @property --ml-y { syntax: "<number>"; initial-value: 0; inherits: true; }
        @property --ml-dx { syntax: "<number>"; initial-value: 0; inherits: true; }
        @property --ml-dy { syntax: "<number>"; initial-value: 0; inherits: true; }
        @property --ml-near { syntax: "<number>"; initial-value: 0; inherits: true; }

        .ml-field {
          display: grid;
          grid-template-columns: repeat(var(--ml-cols), minmax(0, 1fr));
          width: min(100%, calc(var(--ml-cell) * var(--ml-cols)));
          /* кривая намеренно линейная: цель переставляется каждым событием указателя,
             и ease-out, перезапускаясь с нуля, держал бы штрихи в вечном разгоне */
          transition: --ml-x var(--ml-ease) linear, --ml-y var(--ml-ease) linear;
        }

        .ml-cell {
          aspect-ratio: 1;
          display: grid;
          place-items: center;
          /* вектор от клетки к цели считается ЗДЕСЬ, вниз наследуется уже число */
          --ml-dx: calc(var(--ml-x) - var(--ml-cx));
          --ml-dy: calc(var(--ml-y) - var(--ml-cy));
          --ml-near: clamp(0, calc(1 - hypot(var(--ml-dx), var(--ml-dy)) / var(--ml-reach)), 1);
        }

        .ml-line {
          width: calc(var(--ml-cell) * (0.34 + 0.28 * var(--ml-near)));
          height: 2px;
          border-radius: 2px;
          rotate: atan2(var(--ml-dy), var(--ml-dx));
          opacity: calc(0.28 + 0.72 * var(--ml-near));
          background: color-mix(
            in oklch,
            var(--ds-accent, oklch(0.72 0.16 300)),
            oklch(0.62 0.02 265) calc((1 - var(--ml-near)) * 100%)
          );
        }

        /* без CSS-тригонометрии приём не работает, но вёрстка обязана остаться целой:
           поле просто становится ровной штриховкой */
        @supports not (rotate: atan2(1, 1)) {
          .ml-line { rotate: 0deg; opacity: 0.45; }
        }

        @media (prefers-reduced-motion: reduce) {
          .ml-field { transition: none; }
        }
      `}</style>
      {cells.map((i) => {
        const col = i % columns;
        const row = Math.floor(i / columns);
        return (
          <div
            key={i}
            className="ml-cell"
            style={
              {
                // центр клетки в тех же единицах, в которых приходит курсор
                "--ml-cx": col + 0.5,
                "--ml-cy": row + 0.5,
              } as CSSProperties
            }
          >
            <div className="ml-line" />
          </div>
        );
      })}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — ReactBits MagnetLines (MIT + Commons Clause, код не заимствован)v1.0.0