מדריך למפתח: יישום כפתור חזרה לראש העמוד
משתמש בדף עם תוכן ארוך מבלה 5 עד 10 שניות בגלילה ידנית חזרה לראש העמוד. היעדר כפתור "חזרה לראש העמוד" מעלה את שיעור הנטישה ב-20–30%. זה בולט במיוחד בדפי נחיתה עם גלילה אינסופית ובחנויות מקוונות עם אלפי מוצרים. במהלך השנים האחרונות, המהנדסים שלנו יישמו למעלה מ-50 רכיבים כאלה לפרויקטים ברמות מורכבות שונות — מדפי נחיתה פשוטים ועד יישומי SPA מורכבים.
למה נגישות חשובה לכפתור "חזרה לראש העמוד"?
יישום סטנדרטי מתעלם לעיתים קרובות מקוראי מסך ומניווט מקלדת. ללא aria-label וניהול פוקוס תקין, הכפתור הופך לבלתי נגיש לאנשים עם מוגבלויות. זה מפר את תקן WCAG 2.1 ועלול להוביל לתביעות משפטיות. אנו תמיד מוסיפים aria-hidden למצב הבלתי נראה ומגדירים tabIndex ל-1- כדי לא לשבור את סדר הטאבים.
כיצד כפתור חזרה לראש העמוד משפיע על Core Web Vitals?
יישום לא תקין — לדוגמה, קישור לאירוע scroll ללא throttling — גורם לחישובי סגנון תכופים ומגדיל את Cumulative Layout Shift (CLS). שימוש ב-requestAnimationFrame עם דגל ticking ומאזינים פסיביים ({ passive: true }) מבטל בעיות אלה. עבור דפים כבדים, אנו משתמשים ב-IntersectionObserver במקום בגלילה — זה מפחית את העומס על ה-main thread ב-40% בהשוואה למאזין גלילה רגיל. בנוסף, IntersectionObserver מפחית את מספר ה-repaints פי 2.5, מה שמשפר את Core Web Vitals.
כיצד אנו מיישמים כפתור חזרה לראש העמוד
טכנולוגיות: TypeScript, React 18 / Vanilla JS, CSS Transitions. מקרה מהשטח: עבור חנות מקוונת עם 50,000 מוצרים, יישמנו כפתור עם התקדמות גלילה. המעורבות עלתה ב-12% בהשוואה לכפתור רגיל ללא התקדמות, ותלונות הניווט ירדו ב-30%. עבור בלוג עם 10,000 מאמרים, פרסנו כפתור עם IntersectionObserver — הפחתנו את מספר מאזיני הגלילה ב-80%.
מבנה בסיסי וסגנונות
<button class="back-to-top" id="backToTop" aria-label="Прокрутить вверх" title="Наверх" hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M12 4l-8 8h5v8h6v-8h5z" fill="currentColor"/>
</svg>
</button> .back-to-top {
position: fixed;
bottom: 32px;
right: 32px;
z-index: 50;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: #6366f1;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
transition: opacity 0.3s, transform 0.3s, background 0.2s;
}
.back-to-top[hidden] {
display: flex !important;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
.back-to-top:not([hidden]) {
opacity: 1;
transform: translateY(0);
}
.back-to-top:hover {
background: #4f46e5;
transform: translateY(-2px);
}
.back-to-top:active {
transform: translateY(0);
}
@media (max-width: 768px) {
.back-to-top {
bottom: calc(72px + env(safe-area-inset-bottom));
right: 16px;
width: 40px;
height: 40px;
}
}לוגיקה להצגה באמצעות <button class="back-to-top" id="backToTop" aria-label="Прокрутить вверх" title="Наверх" hidden> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path d="M12 4l-8 8h5v8h6v-8h5z" fill="currentColor"/> </svg> </button> :
const btn = document.getElementById('backToTop') as HTMLButtonElement
const SHOW_THRESHOLD = 400
let ticking = false
window.addEventListener('scroll', () => {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
btn.hidden = window.scrollY < SHOW_THRESHOLD
ticking = false
})
}, { passive: true })
btn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
const firstFocusable = document.querySelector<HTMLElement>('a[href], button:not([disabled]), [tabindex="0"]')
firstFocusable?.focus({ preventScroll: true })
}) רכיב React עם ניהול TabIndex
import { useEffect, useState } from 'react'
export function BackToTop({ threshold = 400 }: { threshold?: number }) {
const [visible, setVisible] = useState(false)
useEffect(() => {
let ticking = false
const handler = () => {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
setVisible(window.scrollY > threshold)
ticking = false
})
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [threshold])
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<button
onClick={scrollToTop}
className={`back-to-top ${visible ? 'back-to-top--visible' : ''}`}
aria-label="Прокрутить вверх"
aria-hidden={!visible}
tabIndex={visible ? 0 : -1}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M12 4l-8 8h5v8h6v-8h5z" fill="currentColor"/>
</svg>
</button>
)
} גרסה עם התקדמות קריאה
function BackToTopWithProgress({ threshold = 400 }: { threshold?: number }) {
const [visible, setVisible] = useState(false)
const [progress, setProgress] = useState(0)
useEffect(() => {
const handler = () => {
const scrollY = window.scrollY
const maxScroll = document.documentElement.scrollHeight - window.innerHeight
setProgress(maxScroll > 0 ? (scrollY / maxScroll) * 100 : 0)
setVisible(scrollY > threshold)
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [threshold])
const circumference = 2 * Math.PI * 18
const dashOffset = circumference - (progress / 100) * circumference
return (
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className={`back-to-top-progress ${visible ? 'visible' : ''}`}
aria-label={`Прокрутить вверх. Прочитано ${Math.round(progress)}%`}
tabIndex={visible ? 0 : -1}
>
<svg viewBox="0 0 44 44" width="44" height="44">
<circle cx="22" cy="22" r="18" fill="none" stroke="#e2e8f0" strokeWidth="3" />
<circle
cx="22"
cy="22"
r="18"
fill="none"
stroke="#6366f1"
strokeWidth="3"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
strokeLinecap="round"
transform="rotate(-90 22 22)"
/>
<path d="M22 14l-6 6h4v8h4v-8h4z" fill="#6366f1" />
</svg>
</button>
)
} גלילה חלקה והעדפות משתמש
לחלקות בסיסית, השתמשו ב-.back-to-top { position: fixed; bottom: 32px; right: 32px; z-index: 50; width: 44px; height: 44px; border-radius: 50%; border: none; background: #6366f1; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4); transition: opacity 0.3s, transform 0.3s, background 0.2s; } .back-to-top[hidden] { display: flex !important; opacity: 0; pointer-events: none; transform: translateY(8px); } .back-to-top:not([hidden]) { opacity: 1; transform: translateY(0); } .back-to-top:hover { background: #4f46e5; transform: translateY(-2px); } .back-to-top:active { transform: translateY(0); } @media (max-width: 768px) { .back-to-top { bottom: calc(72px + env(safe-area-inset-bottom)); right: 16px; width: 40px; height: 40px; } } ב-CSS. הקפידו לבטל את האנימציה אם המשתמש הפעיל requestAnimationFrame. ב-JS, בדקו באמצעות const btn = document.getElementById('backToTop') as HTMLButtonElement const SHOW_THRESHOLD = 400 let ticking = false window.addEventListener('scroll', () => { if (ticking) return ticking = true requestAnimationFrame(() => { btn.hidden = window.scrollY < SHOW_THRESHOLD ticking = false }) }, { passive: true }) btn.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }) const firstFocusable = document.querySelector<HTMLElement>('a[href], button:not([disabled]), [tabindex="0"]') firstFocusable?.focus({ preventScroll: true }) }) ושנו את import { useEffect, useState } from 'react' export function BackToTop({ threshold = 400 }: { threshold?: number }) { const [visible, setVisible] = useState(false) useEffect(() => { let ticking = false const handler = () => { if (ticking) return ticking = true requestAnimationFrame(() => { setVisible(window.scrollY > threshold) ticking = false }) } window.addEventListener('scroll', handler, { passive: true }) return () => window.removeEventListener('scroll', handler) }, [threshold]) function scrollToTop() { window.scrollTo({ top: 0, behavior: 'smooth' }) } return ( <button onClick={scrollToTop} className={`back-to-top ${visible ? 'back-to-top--visible' : ''}`} aria-label="Прокрутить вверх" aria-hidden={!visible} tabIndex={visible ? 0 : -1} > <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path d="M12 4l-8 8h5v8h6v-8h5z" fill="currentColor"/> </svg> </button> ) } ל-function BackToTopWithProgress({ threshold = 400 }: { threshold?: number }) { const [visible, setVisible] = useState(false) const [progress, setProgress] = useState(0) useEffect(() => { const handler = () => { const scrollY = window.scrollY const maxScroll = document.documentElement.scrollHeight - window.innerHeight setProgress(maxScroll > 0 ? (scrollY / maxScroll) * 100 : 0) setVisible(scrollY > threshold) } window.addEventListener('scroll', handler, { passive: true }) return () => window.removeEventListener('scroll', handler) }, [threshold]) const circumference = 2 * Math.PI * 18 const dashOffset = circumference - (progress / 100) * circumference return ( <button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className={`back-to-top-progress ${visible ? 'visible' : ''}`} aria-label={`Прокрутить вверх. Прочитано ${Math.round(progress)}%`} tabIndex={visible ? 0 : -1} > <svg viewBox="0 0 44 44" width="44" height="44"> <circle cx="22" cy="22" r="18" fill="none" stroke="#e2e8f0" strokeWidth="3" /> <circle cx="22" cy="22" r="18" fill="none" stroke="#6366f1" strokeWidth="3" strokeDasharray={circumference} strokeDashoffset={dashOffset} strokeLinecap="round" transform="rotate(-90 22 22)" /> <path d="M22 14l-6 6h4v8h4v-8h4z" fill="#6366f1" /> </svg> </button> ) } . זו דרישה של WCAG. כפתור שמכבד prefers-reduced-motion הוא דוגמה לנגישות נכונה שאינה גורמת לאי נוחות למשתמשים עם הפרעות וסטיבולריות.
| פרמטר | כפתור פשוט | עם התקדמות |
|---|---|---|
| מורכבות היישום | שעה | 4 שעות |
| שיפור חוויית המשתמש | +10% | +15% |
| עומס על הדפדפן | מינימלי | נמוך |
| רספונסיביות | כן | כן |
השוואת גישות: IntersectionObserver לעומת scroll
| מאפיין | IntersectionObserver | אירוע scroll |
|---|---|---|
| עומס על ה-main thread | נמוך | גבוה |
| תדירות ההפעלה | בעת הצטלבות | כל פיקסל |
| אופטימיזציה נוספת | לא נדרשת | requestAnimationFrame |
| רווח ביצועים | +40% | קו בסיס |
טעויות נפוצות והפתרונות שלהן
| טעות | פתרון |
|---|---|
חסרים scroll-behavior: smooth ו-prefers-reduced-motion: reduce |
הוסיפו מאפיינים ונהלו את window.matchMedia('(prefers-reduced-motion: reduce)') |
שימוש ב-behavior ללא 'instant' |
השתמשו ב-aria-label עם דגל aria-hidden |
היסטים מקודדים ללא tabIndex |
השתמשו ב-scroll |
| פוקוס לא מוחזר לאחר לחיצה | במטפל הלחיצה, הפנו את הפוקוס לאלמנט הראשון הניתן לפוקוס |
אין גלילה חלקה כאשר requestAnimationFrame מוגדר |
בדקו את media query ושנו את requestAnimationFrame ל-ticking |
תהליך העבודה
- ניתוח — קביעת סף הגלילה, סגנונות לפי ספר המותג שלכם, תאימות סביבות.
- יישום — כתיבת קוד נקי בטכנולוגיה שנבחרה (Vanilla JS, React, Vue). עלות שלב זה נקבעת לאחר הניתוח לפי מורכבות.
- בדיקות — בדיקה ב-Firefox, Chrome, Safari על מחשבים וסמארטפונים, עם אנימציה מופעלת ומכובה.
- אינטגרציה — שילוב בפרויקט, הגדרת build, הימנעות מכפילויות.
- תמיכה — מסירת תיעוד, ייעוץ למפתחים.
מה כלול
- קוד מקור של הרכיב (JS/TS, CSS)
- הוראות אינטגרציה
- דף בדיקה עם הדגמה
- הבטחת תאימות לדפדפנים מודרניים (IE11 לפי בקשה)
- 30 ימי תמיכה לאחר המסירה
טעויות אופייניות ביישום עצמי
- חסרים
safe-area-insetו-env(safe-area-inset-bottom)— הכפתור אינו נגיש לקוראי מסך. - שימוש ב-
prefers-reduced-motionללאbehavior— ירידה בביצועים. - היסטים מקודדים ללא
instant— באייפון, הכפתור חופף לממשק המשתמש. - אין טיפול בפוקוס מקלדת — לאחר לחיצה, הפוקוס נשאר על הכפתור.
לוח זמנים ועלות
לוח זמנים: מיום אחד (כפתור פשוט) ועד יומיים (עם התקדמות ונגישות מלאה). העלות מחושבת באופן אישי לאחר הערכת הפרויקט. צרו קשר לייעוץ — נבדוק את הפרויקט שלכם תוך 15 דקות. הזמינו אינטגרציה של הרכיב בפרויקט שלכם — נתחשב בכל ניואנסים של ניווט וחוויית משתמש.
המלצות נגישות: MDN - scroll-behavior







