DraftPointer
Как мы две недели делали идеальный кастомный курсор — и почему это было зря Введение
Как мы две недели делали идеальный кастомный курсор — и почему это было зря Введение
Идея звучит просто: заменить системный курсор красивым кастомным маркером и добавить «обнимание» интерактивных элементов (кнопок, ссылок). Итог должен был быть эффектным, «вау», современно-анимационным. Реальность оказалась другой: мы дважды проходили весь круг — от «всё летает» до «всё ломается», — и в конце признали эксперимент неуспешным. Ниже — подробный отчёт, чтобы вы не повторяли наших ошибок. Или — удачи вам, если всё-таки решитесь.
Зачем вообще кастомный курсор?
Визуальная идентичность: «наш» курсор, не как у всех.
Микровзаимодействия: курсор реагирует на контент, обнимает элементы, подчёркивает интерактивность.
Иллюзия «премиального» UX.
На бумаге — идеально. На проде — ад из нюансов.
Начальная цель и первая архитектура
Мы начали с простого:
Системный курсор скрыт, на экране — небольшая точка (div) с position: fixed, которую мы двигаем по координатам мыши.
«Обнимание» (wrap/halo): при наведении на интерактив — увеличиваем кастомный курсор так, чтобы он «обнимал» элемент.
Плавность: сглаживаем движение курсора (интерполяция), а размеры меняем анимацией.
Технологии:
Vue/Nuxt (клиентский компонент)
requestAnimationFrame для плавного движения
motion-v для анимации геометрии (ширина/высота/радиус)
Делегированный hover-слежение за a, button, role="button", .clickable, data-cursor
Хуки маршрутизатора (сброс состояний на навигации)
Учтены: pointerrawupdate, контекстное меню, touch-режим, тёмная тема.
Что пошло не так — и снова так
- «Вялое» движение и рассинхрон
Курсор «догонял» мышь, пока та не попадала на интерактив. Решения:
Ввели инициализацию координат по первому движению, чтобы не было скачка.
Сглаживание сделали адаптивным (равномерный easing, не зависящий от FPS).
Это мы победили. Но…
- «Сбит прицел» при обнимании
Обниматель не совпадал с центром элемента. Причины:
расчёт центра по getBoundingClientRect + рафинированное смещение translate(-50%, -50%);
конфликты с текущим scale/transform и разными box-sizing. Решения:
Считать центр только из raw left/top/width/height, никаких внешних трансформаций;
Не использовать проценты смещения внутри обнимания — фиксировать сдвиг в пикселях (напр., максимум ±20 px) и интерполировать к центру.
Эта проблема решилась — но вылезли новые.
- «Магнитизм» и «съедание» взаимодействий
Играясь со смещением, легко перейти грань, когда обниматель «тянет» элемент (или выглядит так). Мы отключили магнитизм полностью: никакого transform у таргет-элемента, только рамка поверх.
- Контекстное меню (ПКМ)
При вызове системного меню кастомный курсор «замирал», а системный появлялся. Договорились: на время меню прячем кастомный курсор (opacity=0), при клике — возвращаем.
- 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-структуры может «сдвинуть прицел». Закладывайте время на поддержку.
Вывод
Мы потратили две недели с переменной нагрузкой и переменным успехом. Да, у нас бывали моменты, когда казалось: «всё, готово!». Но дальше — очередной уголок сценария, и снова всё расползается. Идеального результата не получилось. Эксперимент мы считаем неуспешным — не потому, что «мы не смогли», а потому, что стоимость поддержки и риск регрессий несопоставимы с приростом пользы.
Если вы задумались о кастомном курсоре:
Либо делайте минималистичный «эффект поверх» (оставляя системный курсор),
Либо приготовьтесь к серьёзной инженерной дисциплине и постоянным регрессиям.
Не повторяйте наших ошибок — или, по крайней мере, удачи вам.