← каталог
Magnet Lines
Поле штрихов смотрит на курсор: угол каждого считает сам CSS через atan2(), скрипт шлёт только координату курсора в единицах клеток. В кадре ни строчки JS, rAF-цикла нет вовсе.
cssmouse-movemagneticintermediate0reduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| columns? | number | 9 | Столбцов в поле. |
| rows? | number | 9 | Строк в поле. |
| cellSize? | number | 44 | Предельный размер клетки, px: на узком экране поле сжимается само. |
| reach? | number | 5 | Радиус влияния В КЛЕТКАХ: дальше него штрих уходит в базовую яркость. |
| ease? | number | 0.45 | Секунд на догон курсора: 0 — штрихи прилипают к нему мгновенно. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/magnet-lines.jsonЧто нужно в проекте до установки
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 } 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