Эффект follow cursor (следования за курсором) в контексте
библиотеки Floating UI реализуется через динамическое позиционирование
плавающего элемента относительно текущего положения указателя мыши. В
отличие от классического привязывания к DOM-элементу, здесь в качестве
«якоря» используется виртуальная точка, координаты которой обновляются
при каждом событии mousemove.
Floating UI предоставляет механизм виртуальных элементов (virtual elements), позволяющий рассчитывать позицию так, как будто существует реальный DOM-узел.
Виртуальный элемент — это объект с методом
getBoundingClientRect, возвращающим координаты, имитирующие
положение элемента на странице.
Простейшая реализация:
const virtualElement = {
getBoundingClientRect() {
return {
x: cursorX,
y: cursorY,
width: 0,
height: 0,
top: cursorY,
left: cursorX,
right: cursorX,
bottom: cursorY,
};
},
};
Ключевая идея: координаты cursorX и cursorY
обновляются при движении мыши, а Floating UI использует эти значения для
вычисления позиции всплывающего элемента.
import { computePosition } from '@floating-ui/dom';
const floatingEl = document.querySelector('#tooltip');
let cursorX = 0;
let cursorY = 0;
const virtualElement = {
getBoundingClientRect() {
return {
x: cursorX,
y: cursorY,
width: 0,
height: 0,
top: cursorY,
left: cursorX,
right: cursorX,
bottom: cursorY,
};
},
};
document.addEventListener('mousemove', async (event) => {
cursorX = event.clientX;
cursorY = event.clientY;
const { x, y } = await computePosition(virtualElement, floatingEl);
Object.assign(floatingEl.style, {
left: `${x}px`,
top: `${y}px`,
});
});
Частые вызовы computePosition могут приводить к нагрузке
на CPU. Используются несколько техник оптимизации:
let frameId = null;
document.addEventListener('mousemove', (event) => {
cursorX = event.clientX;
cursorY = event.clientY;
if (frameId) return;
frameId = requestAnimationFrame(async () => {
const { x, y } = await computePosition(virtualElement, floatingEl);
Object.assign(floatingEl.style, {
left: `${x}px`,
top: `${y}px`,
});
frameId = null;
});
});
Floating UI уже оптимизирован, но важно:
Follow cursor почти всегда требует корректировки позиции, чтобы элемент не перекрывал курсор и оставался в пределах экрана.
Сдвиг от курсора:
import { offset } from '@floating-ui/dom';
computePosition(virtualElement, floatingEl, {
middleware: [offset(10)],
});
Автоматическое изменение стороны:
import { flip } from '@floating-ui/dom';
middleware: [flip()];
Предотвращает выход за границы viewport:
import { shift } from '@floating-ui/dom';
middleware: [shift()];
import { computePosition, offset, flip, shift } from '@floating-ui/dom';
computePosition(virtualElement, floatingEl, {
middleware: [
offset(12),
flip(),
shift({ padding: 8 }),
],
});
По умолчанию позиция вычисляется так, будто курсор — это элемент нулевого размера. Для более точного поведения используются стратегии:
offset(({ placement }) => {
if (placement === 'top') return 10;
return 5;
});
computePosition(virtualElement, floatingEl, {
placement: 'top-start',
});
Follow cursor может работать не только с мышью:
document.addEventListener('touchmove', (event) => {
const touch = event.touches[0];
cursorX = touch.clientX;
cursorY = touch.clientY;
});
document.addEventListener('pointermove', (event) => {
cursorX = event.clientX;
cursorY = event.clientY;
});
Pointer Events объединяют мышь, стилус и касание.
Часто всплывающий элемент отображается только при наведении:
document.addEventListener('mouseenter', () => {
floatingEl.style.display = 'block';
});
document.addEventListener('mouseleave', () => {
floatingEl.style.display = 'none';
});
Либо через opacity:
floatingEl.style.opacity = '1';
floatingEl.style.pointerEvents = 'none';
Резкое следование за курсором может выглядеть неестественно. Добавляется сглаживание:
let currentX = 0;
let currentY = 0;
function animate() {
currentX += (cursorX - currentX) * 0.1;
currentY += (cursorY - currentY) * 0.1;
const virtualElement = {
getBoundingClientRect() {
return {
x: currentX,
y: currentY,
width: 0,
height: 0,
top: currentY,
left: currentX,
right: currentX,
bottom: currentY,
};
},
};
computePosition(virtualElement, floatingEl).then(({ x, y }) => {
floatingEl.style.left = `${x}px`;
floatingEl.style.top = `${y}px`;
});
requestAnimationFrame(animate);
}
animate();
Иногда требуется, чтобы элемент следовал только в пределах контейнера:
const rect = container.getBoundingClientRect();
cursorX = Math.max(rect.left, Math.min(event.clientX, rect.right));
cursorY = Math.max(rect.top, Math.min(event.clientY, rect.bottom));
1. Дергание элемента
requestAnimationFrame2. Смещение относительно курсора
offset3. Выход за границы экрана
shift или flip4. Плохая производительность
mousemoveМожно учитывать размер всплывающего блока:
offset(({ rects }) => {
return rects.floating.height / 2;
});
placement: cursorX > window.innerWidth / 2 ? 'left' : 'right'
computePosition вычисляет итоговую позициюrequestAnimationFrameТакая архитектура позволяет реализовывать сложные интерфейсные эффекты: кастомные тултипы, контекстные подсказки, интерактивные элементы, реагирующие на движение пользователя в реальном времени.