Блог
DraftPointer

DraftPointer

Как мы две недели делали идеальный кастомный курсор — и почему это было зря Введение

Как мы две недели делали идеальный кастомный курсор — и почему это было зря Введение

Идея звучит просто: заменить системный курсор красивым кастомным маркером и добавить «обнимание» интерактивных элементов (кнопок, ссылок). Итог должен был быть эффектным, «вау», современно-анимационным. Реальность оказалась другой: мы дважды проходили весь круг — от «всё летает» до «всё ломается», — и в конце признали эксперимент неуспешным. Ниже — подробный отчёт, чтобы вы не повторяли наших ошибок. Или — удачи вам, если всё-таки решитесь.

Зачем вообще кастомный курсор?

Визуальная идентичность: «наш» курсор, не как у всех.

Микровзаимодействия: курсор реагирует на контент, обнимает элементы, подчёркивает интерактивность.

Иллюзия «премиального» UX.

На бумаге — идеально. На проде — ад из нюансов.

Начальная цель и первая архитектура

Мы начали с простого:

Системный курсор скрыт, на экране — небольшая точка (div) с position: fixed, которую мы двигаем по координатам мыши.

«Обнимание» (wrap/halo): при наведении на интерактив — увеличиваем кастомный курсор так, чтобы он «обнимал» элемент.

Плавность: сглаживаем движение курсора (интерполяция), а размеры меняем анимацией.

Технологии:

Vue/Nuxt (клиентский компонент)

requestAnimationFrame для плавного движения

motion-v для анимации геометрии (ширина/высота/радиус)

Делегированный hover-слежение за a, button, role="button", .clickable, data-cursor

Хуки маршрутизатора (сброс состояний на навигации)

Учтены: pointerrawupdate, контекстное меню, touch-режим, тёмная тема.

Что пошло не так — и снова так

  1. «Вялое» движение и рассинхрон

Курсор «догонял» мышь, пока та не попадала на интерактив. Решения:

Ввели инициализацию координат по первому движению, чтобы не было скачка.

Сглаживание сделали адаптивным (равномерный easing, не зависящий от FPS).

Это мы победили. Но…

  1. «Сбит прицел» при обнимании

Обниматель не совпадал с центром элемента. Причины:

расчёт центра по getBoundingClientRect + рафинированное смещение translate(-50%, -50%);

конфликты с текущим scale/transform и разными box-sizing. Решения:

Считать центр только из raw left/top/width/height, никаких внешних трансформаций;

Не использовать проценты смещения внутри обнимания — фиксировать сдвиг в пикселях (напр., максимум ±20 px) и интерполировать к центру.

Эта проблема решилась — но вылезли новые.

  1. «Магнитизм» и «съедание» взаимодействий

Играясь со смещением, легко перейти грань, когда обниматель «тянет» элемент (или выглядит так). Мы отключили магнитизм полностью: никакого transform у таргет-элемента, только рамка поверх.

  1. Контекстное меню (ПКМ)

При вызове системного меню кастомный курсор «замирал», а системный появлялся. Договорились: на время меню прячем кастомный курсор (opacity=0), при клике — возвращаем.

  1. Touch-режим и возврат к мыши

На тач-устройствах кастомный курсор не нужен, а в гибридных сценариях он мог «оживать» не сразу. Мы переключали режимы: при первом touchstart скрывать, при первом mousemove после этого — включать.

Все эти задачи решаемы. Но дальше началась темная магия…

Ghost cursors («призраки») и почему motion-в здесь опасен

Симптомы:

Иногда на странице оставались «призраки» курсора — старые DOM-узлы, зависшие над кнопками.

Иногда курсор «застывал», а другой экземпляр продолжал жить.

Диагностика показала два источника:

Гидрация/перемонтирование (Nuxt + ClientOnly): если мы переносим элемент в document.body вручную, можно получить расхождение между тем, что «думает» Vue, и реальным DOM. Решалось частично очистками и даже Teleport to="body".

motion-v: при наслаивании анимаций геометрии (width/height/borderRadius), движок может создавать временный snapshot/клон для плавности. Если исходный узел был «вырван» из контекста (перемещён в body вручную), клон мог не удалиться. Именно это и рождало «призраков».

Что мы пробовали:

Жёсткий cleanup перед монтированием (удалять все старые data-ios-cursor).

Удалять ноду в onUnmounted.

Логировать количество курсоров, opacity, translate, «кто живой».

Блокировки анимаций (lock), чтобы не наслаивались.

Очередь анимаций (queue), чтобы быстрые enter/leave не съедали друг друга.

Замена части анимаций на нативный Web Animations API (WAAPI).

Чем это кончилось:

Призраков удалось свести к нулю только ценой жёсткой дисциплины анимаций (никаких одновременных width/height/scale и строгая последовательность), плюс аккуратный монтёж в body.

Но как только «улучшаешь» UX (быстрые переходы кнопка→кнопка, клик-пульс, изменение радиуса и размеров без схлопывания) — снова возникает риск перекрытия анимаций и баги возвращаются.

Ещё четыре «мелочи», которые отнимают дни

Клики и обнимание: во время клика обнимание не должно схлопываться — но если в этот момент приходит pointerleave/pointerenter на дочерних узлах, легко потерять состояние и получить дергания. Нужна специальная логика: «пока клик — держать состояние и переобнимать только на соседний интерактив».

Кнопка→кнопка без схлопывания: вместо leave → collapse → enter → expand нужно делать modify-in-place: сразу считать новую геометрию и анимировать из текущих размеров к новым. Это работает, пока очереди анимаций не конфликтуют.

Стартовая позиция: при загрузке курсор должен быть в центре, а не в (0,0), но до первого mousemove он может «зависнуть» посередине, что визуально странно. Мы это приняли как компромисс — иначе появляются скачки.

Темы и backdrop: смена темы или backdrop-фильтров иногда перерассчитывает слои, что дергает пересчёт размеров и фоновые эффекты. Тоже лечится, но добавляет к общей хрупкости.

К чему мы пришли: «системный по умолчанию + лёгкий эффект поверх»

Итог двух недель (с перерывами и откатами): идеального кастомного курсора без побочных эффектов мы не получили. Каждый раз, когда удавалось довести один сценарий до идеала, отваливалось что-то другое — то на кликах, то на быстрых переходах, то при монтировании, то при SSR-гидрации.

Рациональный компромисс:

Системный курсор — оставить. Не прятать. Он предсказуем и дружит с ОС/доступностью/контекстным меню/текстовым вводом.

Лёгкий визуальный слой поверх — «обнимание» интерактивов (рамка/хало) без слежения за мышью. Это даёт большую часть «вау-эффекта», но не ломает базовую навигацию.

Включать по фичефлагу (по умолчанию выключено), отключать при prefers-reduced-motion.

Метрить, а не верить: если метрики UX действительно выигрывают — масштабировать точечно.

Рекомендации тем, кто всё-таки хочет «полный кастом»

Если упрётесь рогом (мы вас предупреждали 😅), вот минимальный набор гигиены:

Не меняйте много свойств сразу. Для motion-v опасна комбинация width/height/borderRadius/scale в одном такте и при быстрых повторах. Разделяйте анимации или стройте очередь.

Очередь вместо блокировки. Не «глотайте» события enter/leave и click — кладите их в очередь, исполняйте последовательно.

Телепорт/монтирование. Или полностью отдайте монтаж в руки Vue через Teleport to="body", или сами жёстко контролируйте appendChild и чистку. Полумеры дают «призраков».

Диагностика на проде. Легко добавить счётчик и логгер (document.querySelectorAll('data-ios-cursor').length). Если >1 — проблема вернулась.

Кнопка→кнопка — без коллапса. При переходе сразу считайте новую геометрию и анимируйте размеры из текущих, не вызывая «схлопывание».

Клик не должен сбрасывать ховер. Во время pointerdown держите «обнимание» активным, а после pointerup сверяйте, что по-прежнему над интерактивом — и только тогда решайте, что делать.

Touch-гигиена. На тачах не нужно вообще; гибрид — только после реального mousemove включать.

Аксессибилити. prefers-reduced-motion, контраст, правильная видимость фокуса с клавиатуры (не ломайте его кастомом).

Готовьтесь к регрессиям. Малейшее изменение CSS/DOM-структуры может «сдвинуть прицел». Закладывайте время на поддержку.

Вывод

Мы потратили две недели с переменной нагрузкой и переменным успехом. Да, у нас бывали моменты, когда казалось: «всё, готово!». Но дальше — очередной уголок сценария, и снова всё расползается. Идеального результата не получилось. Эксперимент мы считаем неуспешным — не потому, что «мы не смогли», а потому, что стоимость поддержки и риск регрессий несопоставимы с приростом пользы.

Если вы задумались о кастомном курсоре:

Либо делайте минималистичный «эффект поверх» (оставляя системный курсор),

Либо приготовьтесь к серьёзной инженерной дисциплине и постоянным регрессиям.

Не повторяйте наших ошибок — или, по крайней мере, удачи вам.

Built with Nuxt UI • © 2025