Реалізація анімації курсора на сайті
Кастомний курсор — один із тих елементів, де різниця між "зробили" та "зробили добре" видна одразу. Погана реалізація дає відчуття важкості: курсор запізнюється, дергається, конфліктує з нативними станами браузера. Правильна — працює непомітно, посилює характер інтерфейсу.
Анатомія кастомного курсора
Типова структура: два елементи — точка (dot), яка слідує за мишею без затримки, та кільце (ring/follower), яке тягнеться з 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)
// Hover-стани для інтерактивних елементів
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
// Dot слідує без затримки через CSS transform
this.dot.style.left = `${e.clientX}px`
this.dot.style.top = `${e.clientY}px`
}
private tick = () => {
// Follower з lerp
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-атрибути.
/* Hover на кнопках — збільшення з заливкою */
.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;
}
/* View/play — курсор з текстом */
.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;
}
Інтеграція з React
У React-проектах курсор реалізується як глобальний компонент через Context або store. Важливо: монтувати лише один раз, не перерендерювати при кожному русі миші.
import { useEffect, useRef, useCallback } from 'react'
import { useMotionValue, useSpring, motion } from 'framer-motion'
export function CustomCursor() {
const mouseX = useMotionValue(-100)
const mouseY = useMotionValue(-100)
// Spring для follower — альтернатива ручному RAF+lerp
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)
}, [])
// Скриваємо на touch-пристроях
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%' }}
/>
</>
)
}
Нативний курсор як fallback
На тач-пристроях кастомний курсор відключається повністю. cursor: none застосовується лише при (hover: hover) and (pointer: fine):
@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; }
}
Типові грабли
Мерцання при швидкому русі — dot та ring рендерятся у різних шарах. Розв'язання: обидва елементи в одному stacking context, will-change: transform на кожному.
Конфлікт з iframe — при уходженні курсора в iframe подія mousemove не стріляє. Потрібно відслідковувати mouseleave на document та приховувати курсор.
Затримка CSS transitions — якщо на ring стоїть transition без виключення left/top, follower теряє живість. Переходи лише для scale/opacity/color, позиція — через transform без transition.
Терміни
Базова реалізація з dot + ring та hover-станами — 1 день. З анімацією тексту, drag-курсором для слайдерів, інтеграцією в існуючий React-проект та повним набором станів — 2–3 дні.







