מדריך מלא ליישום אנימציית סמן מותאם אישית ב-60 fps
המבקר מזיז את העכבר, אבל הסמן קופץ, מפגר או נעלם — מוכר? לרוב הבעיה היא חוסר אופטימיזציה נכונה. ניתחנו פרויקטים שבהם מפתחים השתמשו באנימציית jQuery, מה שגרם לפיגור במכשירים ניידים. הגישה הנכונה היא שימוש ב-will-change: transform ו-CSS cursor: none. ב-60 fps, עיכוב העוקב עם lerp 0.12 הוא בערך 8-10 אלפיות השנייה — בלתי מורגש לעין.
אנחנו מפתחים סמנים מותאמים אישית לאתרים — מאפקטי נגררים פשוטים ועד מצבים מורכבים עם התאמה לממשק. עם ניסיון של למעלה מ-5 שנים ו-20+ פרויקטי סמן מוצלחים, אנחנו מבטיחים ללא הבהובים ו-60fps עקביים. יישום גרוע מתגלה דרך פיגור, גמגום וקונפליקטים עם מצבי דפדפן מקוריים. הצוות שלנו, המנוסה בממשקים אינטראקטיביים, מבטיח שהסמן יעבוד בצורה חלקה וישפר את האופי של הפרויקט שלך. קבל ייעוץ מהנדס — כתוב לנו, נעריך את הפרויקט שלך תוך יום אחד.
אנטומיה ולוגיקת תנועה
מבנה טיפוסי: שני אלמנטים — נקודה שעוקבת אחרי העכבר ללא עיכוב, ועיגול עוקב שנגרר עם אפקט lerp. במקום * { 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; } על כל המסמך, אנחנו מסתירים אותו רק במקום שצריך.
* {
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 עם אינטרפולציה לינארית לעוקב. מיקום הנקודה מתעדכן ישירות ב-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)
}
} מצבי סמן והיישום שלהם
סט סטנדרטי: ברירת מחדל, ריחוף, פעיל, טקסט, גרירה, תצוגה. מעבר דרך מחלקות CSS ותכונות נתונים.
| מצב | מחלקת CSS | תיאור חזותי |
|---|---|---|
| ברירת מחדל | .cursor-ring--default | טבעת קטנה ללא מילוי |
| ריחוף | .cursor-ring--hover | עיגול מוגדל עם מילוי חצי שקוף |
| פעיל | .cursor-ring--active | עיגול מכווץ |
| טקסט | .cursor-ring--text | פס אנכי |
| גרירה | .cursor-ring--drag | יד סגורה |
| תצוגה | .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;
} איך ליישם סמן מותאם אישית ללא הבהובים?
הבהובים מתרחשים כאשר הנקודה והעוקב מעובדים בשכבות קומפוזיציה שונות. פתרון — שני האלמנטים באותו הקשר ערימה ו-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) } } על כל אחד. אנחנו גם משתמשים ב-.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; } כך שהסמן לא חוסם קליקים. בפועל, זה נותן חלקות ב-99% מהמקרים — אפילו ב-60 fps. לאחר אופטימיזציה זו, LCP לא גדל, והתחושה הסובייקטיבית של מהירות משתפרת ב-20-30%.
למה חשוב להשבית את הסמן במכשירי מגע?
במכשירי קלט מגע, סמן מותאם אישית הוא חסר משמעות — הוא ייתקע בנקודת המגע האחרונה. אנחנו משביתים אותו דרך שאילתת המדיה will-change: transform, ומשאירים את סמן הדפדפן המקורי. זה משפר את הנגישות וחוסך בסוללה — עד 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 — זה פי 3 טוב יותר במהירות הפיתוח:
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%' }} />
</>
)
} מלכודות טיפוסיות והפתרונות שלהן
- הבהובים בתנועה מהירה — נקודה וטבעת בשכבות שונות. פתרון: הקשר ערימה ו-
pointer-events: none. - קונפליקט עם iframe — כשהסמן נכנס ל-iframe,
@media (hover: none) and (pointer: coarse)לא מופעל. עקוב אחר@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; } }על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%' }} /> </> ) }והסתר את הסמן. - עיכוב מעבר CSS — אם מוגדר
will-change: transformעל הטבעת ללא החרגתmousemove, העוקב מאבד חיוניות. מעברים רק עבור scale/opacity/color.
השוואת גישות: RAF לעומת framer-motion
| פרמטר | RAF טהור + lerp | framer-motion useSpring |
|---|---|---|
| גודל קוד | ~50 שורות של אנימציה | ~15 שורות עם handler |
| ביצועים | גבוהים (0 תלויות נוספות) | גבוהים (RAF מקורי) |
| גמישות | שליטה מלאה (easing מותאם) | מוגבל לפיזיקת קפיץ |
| אינטגרציית React | דורש ref + useEffect | מובנה (useMotionValue) |
| תמיכה | כל דפדפן | Framer Motion (React) |
| זמן פיתוח | 3-4 שעות | שעה אחת |
בפועל, framer-motion מאיץ את הפיתוח פי 3 ומספק חלקות ב-60 fps. אם הפרויקט על Vue או vanilla JS — אנחנו משתמשים ב-RAF טהור.
מה כלול בעבודה
- קוד מקור של רכיב הסמן (TypeScript + CSS)
- תיעוד API ומצבים
- הוראות אינטגרציה ל-React, Vue או vanilla JS
- תמיכה ל-14 ימים לאחר המסירה
- ייעוץ להתאמה למכשירי מגע
תוכנית עבודה ולוח זמנים
| שלב | פעילות | משך |
|---|---|---|
| ניתוח עיצוב | קביעת עד 10 מצבי סמן | 4–8 שעות |
| אב טיפוס | יצירת דף בדיקה עם canvas או DOM | 2–4 שעות |
| אינטגרציה | חיבור הרכיב לפריימוורק | 4–8 שעות |
| בדיקות | בדיקה על 10+ תצורות מכשירים | 2–4 שעות |
| תיעוד | תיאור ה-API למעבר מצבים | 2–4 שעות |
יישום בסיסי עם נקודה + טבעת ומצבי ריחוף — מיום אחד, החל מ-$500. עם סט של 6 מצבים, התאמת React ובדיקות מלאות — 2–3 ימים, עד $2,000. בזכות framer-motion, קיצרנו את זמן הפיתוח פי 3, מה שמספק חיסכון משמעותי בתקציב.
הזמן סמן מותאם אישית מוכן לשימוש — כתוב למהנדסים שלנו. נעריך את הפרויקט שלך תוך יום אחד ונבחר את הפתרון האופטימלי.







