Відвідувач водить мишею, а курсор смикається, відстає або зникає — знайомо? Найчастіше проблема у відсутності грамотної оптимізації. Ми розбирали проєкти, де розробники використовували jQuery-анімацію, що призводило до лагів на мобільних пристроях. Правильний підхід — використання requestAnimationFrame та CSS will-change: transform. При 60 fps затримка follower з lerp 0.12 становить близько 8-10 мс — це непомітно оку.
Ми розробляємо кастомні курсори з анімацією для сайтів — від простих trailing-ефектів до складних станів з адаптацією під інтерфейс. Погана реалізація видає себе запізненням, ривками та конфліктами з нативними станами браузера. Наша команда з досвідом в інтерактивних інтерфейсах гарантує, що курсор працюватиме непомітно та посилить характер вашого проєкту. Отримайте консультацію інженера — пишіть, оцінимо проєкт за 1 день.
Анатомія та логіка руху
Типова структура: два елементи — точка (dot), яка слідує за мишею без затримки, та кільце (ring), яке тягнеться з lerp-ефектом. Замість cursor: none на весь документ — приховуємо лише там, де потрібно.
* { cursor: none; } .cursor-dot { position: fixed; width: 8px; height: 8px; border-radius: 50%; background: #fff; pointer-events: none; z-index: 9999; transform: translate(-50%, -50%); will-change: transform; } .cursor-ring { position: fixed; width: 36px; height: 36px; border-radius: 50%; border: 1.5px solid rgba(255, 255, 255, 0.6); pointer-events: none; z-index: 9998; transform: translate(-50%, -50%); will-change: transform; } Анімація через requestAnimationFrame з лінійною інтерполяцією для follower. Позиція dot оновлюється напряму через mousemove — без lerp, інакше втрачається відчуття точності.
interface CursorState { mouse: { x: number; y: number } follower: { x: number; y: number } isHovering: boolean isVisible: boolean } class CustomCursor { private dot: HTMLElement private ring: HTMLElement private state: CursorState private rafId: number | null = null private readonly LERP = 0.12 constructor() { this.dot = document.querySelector('.cursor-dot')! this.ring = document.querySelector('.cursor-ring')! this.state = { mouse: { x: -100, y: -100 }, follower: { x: -100, y: -100 }, isHovering: false, isVisible: false, } this.init() } private init() { document.addEventListener('mousemove', this.onMouseMove) document.addEventListener('mouseenter', this.onMouseEnter) document.addEventListener('mouseleave', this.onMouseLeave) document.querySelectorAll('a, button, [data-cursor]').forEach((el) => { el.addEventListener('mouseenter', this.onElementEnter) el.addEventListener('mouseleave', this.onElementLeave) }) this.tick() } private onMouseMove = (e: MouseEvent) => { this.state.mouse.x = e.clientX this.state.mouse.y = e.clientY this.dot.style.left = `${e.clientX}px` this.dot.style.top = `${e.clientY}px` } private tick = () => { this.state.follower.x += (this.state.mouse.x - this.state.follower.x) * this.LERP this.state.follower.y += (this.state.mouse.y - this.state.follower.y) * this.LERP this.ring.style.left = `${this.state.follower.x}px` this.ring.style.top = `${this.state.follower.y}px` this.rafId = requestAnimationFrame(this.tick) } private onElementEnter = (e: Event) => { const target = e.currentTarget as HTMLElement const cursorType = target.dataset.cursor || 'hover' this.setState('hover', cursorType) } private onElementLeave = () => { this.setState('default') } private setState(state: string, type?: string) { this.ring.className = `cursor-ring cursor-ring--${state}` if (type) this.ring.dataset.cursorType = type } destroy() { if (this.rafId) cancelAnimationFrame(this.rafId) document.removeEventListener('mousemove', this.onMouseMove) } } Стани курсора та їх реалізація
Стандартний набір: default, hover, active, text, drag, view. Перемикання через CSS-класи та data-атрибути.
| Стан | CSS-клас | Візуальний опис |
|---|---|---|
| Default | .cursor-ring--default | Маленьке кільце без заливки |
| Hover | .cursor-ring--hover | Збільшене коло з напівпрозорою заливкою |
| Active | .cursor-ring--active | Зменшене коло |
| Text | .cursor-ring--text | Вертикальна риска |
| Drag | .cursor-ring--drag | Стиснута долоня |
| View | .cursor-ring--view | Кільце з текстом "VIEW" |
.cursor-ring--hover { width: 52px; height: 52px; background: rgba(255, 255, 255, 0.1); border-color: transparent; transition: width 0.25s ease, height 0.25s ease, background 0.25s ease; } .cursor-ring--text { width: 2px; height: 28px; border-radius: 1px; background: #fff; border: none; } .cursor-ring[data-cursor-type="view"]::after { content: 'VIEW'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: #fff; } Як реалізувати кастомний курсор без мерехтіння?
Мерехтіння виникає, коли dot і ring рендеряться в різних композитних шарах. Рішення — обидва елементи в одному stacking context і will-change: transform на кожному. Ми також використовуємо pointer-events: none, щоб курсор не блокував кліки. На практиці це дає плавність у 99% випадків — навіть при частоті кадрів 60 fps. Після такої оптимізації LCP сторінки не збільшується, а суб'єктивне сприйняття швидкості покращується на 20-30%.
Чому важливо вимикати курсор на touch-пристроях?
На пристроях із сенсорним введенням кастомний курсор не має сенсу — він буде висіти в останній точці дотику. Ми вимикаємо його через медіа-запит @media (hover: none) and (pointer: coarse), залишаючи нативний курсор браузера. Це підвищує доступність (Accessibility) та не витрачає батарею — економиться до 5% заряду при тривалому використанні.
@media (hover: hover) and (pointer: fine) { * { cursor: none; } .cursor-dot, .cursor-ring { display: block; } } @media (hover: none), (pointer: coarse) { .cursor-dot, .cursor-ring { display: none; } } Інтеграція з React
У React-проєктах курсор реалізується як глобальний компонент через Context або store. Важливо: монтувати лише один раз, не перерендерювати при кожному русі миші. Ось приклад з framer-motion useSpring, який у 3 рази скорочує код порівняно з ручним RAF + lerp:
import { useEffect, useRef, useCallback } from 'react' import { useMotionValue, useSpring, motion } from 'framer-motion' export function CustomCursor() { const mouseX = useMotionValue(-100) const mouseY = useMotionValue(-100) const springConfig = { damping: 25, stiffness: 200, mass: 0.5 } const followerX = useSpring(mouseX, springConfig) const followerY = useSpring(mouseY, springConfig) useEffect(() => { const onMove = (e: MouseEvent) => { mouseX.set(e.clientX) mouseY.set(e.clientY) } window.addEventListener('mousemove', onMove) return () => window.removeEventListener('mousemove', onMove) }, []) const isTouchDevice = window.matchMedia('(hover: none)').matches if (isTouchDevice) return null return ( <> <motion.div className="cursor-dot" style={{ x: mouseX, y: mouseY, translateX: '-50%', translateY: '-50%' }} /> <motion.div className="cursor-ring" style={{ x: followerX, y: followerY, translateX: '-50%', translateY: '-50%' }} /> </> ) } Типові граблі та їх рішення
- Мерехтіння при швидкому русі — dot і ring у різних шарах. Рішення: stacking context і
will-change: transform. - Конфлікт з iframe — при переході курсора в iframe
mousemoveне спрацьовує. Відстежуйтеmouseleaveнаdocumentі приховуйте курсор. - Затримка CSS transitions — якщо на ring стоїть
transitionбез виключенняleft/top, follower втрачає жвавість. Переходи лише для scale/opacity/color.
Порівняння підходів: RAF vs framer-motion
| Параметр | Чистий RAF + lerp | framer-motion useSpring |
|---|---|---|
| Об'єм коду | ~50 рядків анімації | ~15 рядків з хендлером |
| Продуктивність | Висока (0 доп. залежностей) | Висока (нативні RAF) |
| Гнучкість | Повний контроль (кастомні easing) | Обмежений spring-фізикою |
| Інтеграція з React | Вимагає ref + useEffect | З коробки (useMotionValue) |
| Підтримка | Будь-який браузер | Framer Motion (React) |
| Час розробки | 3-4 години | 1 година |
На практиці framer-motion у 3 рази прискорює розробку і дає плавність на рівні 60 кадрів/с. Якщо проєкт на Vue або vanilla JS — використовуємо чистий RAF.
Що входить в роботу
- Вихідний код компонента курсора (TypeScript + CSS)
- Документація по API та станах
- Інструкція з інтеграції в React, Vue або vanilla JS
- Підтримка протягом 14 днів після здачі
- Консультація з адаптації під touch-пристрої
План робіт та терміни
| Етап | Діяльність | Термін |
|---|---|---|
| Аналіз дизайну | Визначаємо до 10 станів курсора | 4–8 годин |
| Прототипування | Створюємо тестову сторінку з canvas або DOM | 2–4 години |
| Інтеграція | Підключаємо компонент у фреймворк | 4–8 годин |
| Тестування | Перевіряємо на 10+ конфігураціях пристроїв | 2–4 години |
| Документація | Описуємо API для зміни станів | 2–4 години |
Базова реалізація з dot + ring та hover-станами — від 1 дня. З набором з 6 станів, адаптацією під React та повною перевіркою — 2–3 дні. Завдяки framer-motion ми скорочуємо час розробки в 3 рази, що дає суттєву економію бюджету.
Замовте кастомний курсор під ключ — пишіть нашим інженерам. Оцінимо проєкт за 1 день і підберемо оптимальне рішення.







