כיצד להימנע מפגיעה ב-Core Web Vitals ובנגישות באקורדיונים ובטאבים
לעתים קרובות אנו נתקלים בפרויקטים שבהם אקורדיונים וטאבים מיושמים ללא התחשבות בביצועים ובנגישות. בעיות אופייניות: אנימציית גובה עם height: auto גורמת ל-Layout Shift, תוכן של פאנלים מוסתרים אינו ממו�索引, והיעדר תכונות ARIA הופך רכיבים לבלתי נגישים לקוראי מסך. התוצאה — LCP נמוך, בדיקת Lighthouse נכשלת, ואובדן של עד 30% מתעבורת המובייל. הניסיון שלנו מראה שיישום נכון של רכיבים אלה במתכונת turnkey אורך 2 עד 3 ימים וכולל: רינדור בצד השרת, סנכרון URL, סימון Schema.org ותמיכה מלאה ב-WCAG 2.1 AA. להלן פתרונות ספציפיים שאנו מיישמים בסביבת ייצור.
אילו בעיות אנו פותרים?
הימנעות מ-CLS באנימציית אקורדיון
שימוש ב-height: auto במעברי CSS גורם לחישוב מחדש של הפריסה. הפתרון הוא JavaScript המודד את scrollHeight ומגדיר ערך קבוע. השתמשנו בגישה זו ביותר מ-30 פרויקטים. חלופה היא grid-template-rows: 0fr / 1fr של CSS עם overflow: hidden. אך גרסת ה-JS מבטיחה אנימציה חלקה בכל הדפדפנים, כולל Safari ישן. שיטה זו מבטלת את CLS לחלוטין, ומשפרת את ציוני Cumulative Layout Shift ב-0.2 בממוצע.
הבטחת אינדוקס של תוכן טאבים
Google מאנדקס תוכן עם display: none, אך נותן לו משקל נמוך יותר. עבור תוכן קריטי ל-SEO, אנו משתמשים ב-visibility: hidden + position: absolute וברינדור בצד השרת של כל הפאנלים. השוואה: עם הסתרה בצד הלקוח, התוכן מקבל משקל של 0.3 מתוכן פתוח, בעוד שעם SSR הוא מקבל משקל מלא. בפרויקט אחד, זה הגדיל את נראות הדף ב-25% לפי Yandex.Webmaster. רינדור בצד השרת טוב פי 5 ל-SEO מאשר הסתרה בצד הלקוח.
נגישות מקלדת לטאבים
אנו מיישמים את התבנית role="tablist" עם ArrowLeft/Right, Home/End. ניהול הפוקוס נעשה באמצעות tabIndex. ללא זה, הרכיב נכשל ב-WCAG 2.1 AA. עם הגישה שלנו, ניווט מקלדת תואם לחלוטין ונבדק עם קוראי מסך.
איך אנחנו עושים את זה: טכנולוגיות ודוגמה מעשית
אנו משתמשים ב-React 18, Next.js 14, TypeScript, Tailwind. לאנימציה — מעברי CSS עם בקרת גובה ב-JS. דוגמת אקורדיון (בקיצור):
import { useState, useRef, useEffect } from 'react'
interface AccordionItem {
id: string
title: string
content: React.ReactNode
}
function AccordionItem({
item,
isOpen,
onToggle,
}: {
item: AccordionItem
isOpen: boolean
onToggle: () => void
}) {
const contentRef = useRef<HTMLDivElement>(null)
const [height, setHeight] = useState(0)
useEffect(() => {
if (contentRef.current) {
setHeight(isOpen ? contentRef.current.scrollHeight : 0)
}
}, [isOpen])
return (
<div className="accordion-item">
<button
className="accordion-item__trigger"
onClick={onToggle}
aria-expanded={isOpen}
aria-controls={`accordion-content-${item.id}`}
id={`accordion-btn-${item.id}`}
>
{item.title}
</button>
<div
id={`accordion-content-${item.id}`}
role="region"
aria-labelledby={`accordion-btn-${item.id}`}
style={{ height, overflow: 'hidden', transition: 'height 0.3s ease' }}
>
<div ref={contentRef} className="accordion-item__body">
{item.content}
</div>
</div>
</div>
)
}לטאבים, אנו תמיד מוסיפים סנכרון URL באמצעות import { useState, useRef, useEffect } from 'react' interface AccordionItem { id: string title: string content: React.ReactNode } function AccordionItem({ item, isOpen, onToggle }: { item: AccordionItem isOpen: boolean onToggle: () => void }) { const contentRef = useRef<HTMLDivElement>(null) const [height, setHeight] = useState(0) useEffect(() => { if (contentRef.current) { setHeight(isOpen ? contentRef.current.scrollHeight : 0) } }, [isOpen]) return ( <div className="accordion-item"> <button className="accordion-item__trigger" onClick={onToggle} aria-expanded={isOpen} aria-controls={`accordion-content-${item.id}`} id={`accordion-btn-${item.id}`} > {item.title} </button> <div id={`accordion-content-${item.id}`} role="region" aria-labelledby={`accordion-btn-${item.id}`} style={{ height, overflow: 'hidden', transition: 'height 0.3s ease' }} > <div ref={contentRef} className="accordion-item__body"> {item.content} </div> </div> </div> ) } וסימון Schema.org עבור FAQ. דוגמת טאבים עם ניווט מקלדת:
import { useState, useRef } from 'react'
interface Tab {
id: string
label: string
content: React.ReactNode
}
export function Tabs({ tabs, defaultTab }: { tabs: Tab[]; defaultTab?: string }) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id)
const tablistRef = useRef<HTMLDivElement>(null)
function handleKeyDown(e: React.KeyboardEvent, currentIndex: number) {
let newIndex = currentIndex
if (e.key === 'ArrowRight') newIndex = (currentIndex + 1) % tabs.length
else if (e.key === 'ArrowLeft') newIndex = (currentIndex - 1 + tabs.length) % tabs.length
else if (e.key === 'Home') newIndex = 0
else if (e.key === 'End') newIndex = tabs.length - 1
else return
e.preventDefault()
setActiveId(tabs[newIndex].id)
const tabEls = tablistRef.current?.querySelectorAll('[role="tab"]')
;(tabEls?.[newIndex] as HTMLElement)?.focus()
}
return (
<div className="tabs">
<div role="tablist" ref={tablistRef} className="tabs__list">
{tabs.map((tab, i) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeId === tab.id}
aria-controls={`tabpanel-${tab.id}`}
tabIndex={activeId === tab.id ? 0 : -1}
onClick={() => setActiveId(tab.id)}
onKeyDown={e => handleKeyDown(e, i)}
className={`tabs__tab ${activeId === tab.id ? 'tabs__tab--active' : ''}`}
>
{tab.label}
</button>
))}
</div>
{tabs.map(tab => (
<div
key={tab.id}
role="tabpanel"
id={`tabpanel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeId !== tab.id}
tabIndex={0}
className="tabs__panel"
>
{tab.content}
</div>
))}
</div>
)
}רינדור בצד השרת של כל הפאנלים פותר בעיות SEO. ב-Next.js, אנו משתמשים ב-useSearchParams בדף עם טאבים או מרנדרים את כל הפאנלים ב-HTML, ומסתירים אותם באמצעות CSS (לא import { useState, useRef } from 'react' interface Tab { id: string label: string content: React.ReactNode } export function Tabs({ tabs, defaultTab }: { tabs: Tab[]; defaultTab?: string }) { const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id) const tablistRef = useRef<HTMLDivElement>(null) function handleKeyDown(e: React.KeyboardEvent, currentIndex: number) { let newIndex = currentIndex if (e.key === 'ArrowRight') newIndex = (currentIndex + 1) % tabs.length else if (e.key === 'ArrowLeft') newIndex = (currentIndex - 1 + tabs.length) % tabs.length else if (e.key === 'Home') newIndex = 0 else if (e.key === 'End') newIndex = tabs.length - 1 else return e.preventDefault() setActiveId(tabs[newIndex].id) const tabEls = tablistRef.current?.querySelectorAll('[role="tab"]') ;(tabEls?.[newIndex] as HTMLElement)?.focus() } return ( <div className="tabs"> <div role="tablist" ref={tablistRef} className="tabs__list"> {tabs.map((tab, i) => ( <button key={tab.id} role="tab" id={`tab-${tab.id}`} aria-selected={activeId === tab.id} aria-controls={`tabpanel-${tab.id}`} tabIndex={activeId === tab.id ? 0 : -1} onClick={() => setActiveId(tab.id)} onKeyDown={e => handleKeyDown(e, i)} className={`tabs__tab ${activeId === tab.id ? 'tabs__tab--active' : ''}`} > {tab.label} </button> ))} </div> {tabs.map(tab => ( <div key={tab.id} role="tabpanel" id={`tabpanel-${tab.id}`} aria-labelledby={`tab-${tab.id}`} hidden={activeId !== tab.id} tabIndex={0} className="tabs__panel" > {tab.content} </div> ))} </div> ) } , אלא force-dynamic).
דוגמה נוספת: אקורדיון עם אנימציית CSS Grid
.accordion__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.accordion__panel--open {
grid-template-rows: 1fr;
}שיטה זו מבטלת את הצורך ב-JS אך אינה מספקת חלקות בדפדפנים ישנים.
תהליך העבודה
| שלב | משך | תוצאה |
|---|---|---|
| ביקורת על היישום הנוכחי | 1-2 שעות | דוח על Lighthouse, WAVE, CWV |
| עיצוב הרכיב | 2-4 שעות | תכנית ARIA, עיצוב אנימציה |
| פיתוח (HTML+CSS+JS) | 4-6 שעות | רכיבים טבעיים |
| פיתוח עטיפת React | 6-8 שעות | רכיבים עם SSR, URL, Schema |
| בדיקות | 2-4 שעות | מקלדת, קורא מסך, בדיקת ביצועים |
| פריסה ותיעוד | 2-4 שעות | מיזוג Git, readme, הדרכת צוות |
לוחות זמנים ומה כלול
| רכיב | לוח זמנים |
|---|---|
| אקורדיון פשוט (טבעי) | 2-3 שעות |
| אקורדיון React עם אנימציה | 4-6 שעות |
| מערכת טאבים עם URL ו-Schema | 1-1.5 ימים |
| סט מלא (אקורדיון+טאבים) | 2-3 ימים |
כלול בעבודה:
- קוד מקור של הרכיבים (TypeScript, CSS modules או Tailwind)
- תיעוד על API ותכונות ARIA
- אינטגרציה לפרויקט קיים (Git)
- הדרכת צוות (שעה)
- תמיכה למשך שבועיים לאחר הפריסה
עלות וחיסכון: היישום המלא שלנו עולה $2,500 לפרויקט, אך הלקוחות חוסכים בממוצע $5,000 בשנה בהוצאות פרסום מופחתות הודות לשיפור ביצועי דף הנחיתה. עבור אתר מסחר אלקטרוני גדול, זה יכול לעלות על $20,000 בשנה.
טעויות אופייניות
- שימוש ב-
display:noneלאנימציית גובה מוביל ל-CLS. עדיף להשתמש ב-JS. - שכחת
position:absolute; visibility:hidden; height:0; overflow:hidden— השבתת אנימציה. - אי בדיקת מצב טעינה — רכיב hydrated עלול "להבהב".
השוואת גישות
| קריטריון | HTML טבעי (.accordion__panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease; } .accordion__panel--open { grid-template-rows: 1fr; } ) |
רכיב React (אנימציית JS) |
|---|---|---|
| זמן פיתוח | שעה | 4-6 שעות |
| אנימציה | CSS בלבד (לא עובד ללא overflow: hidden) |
חלקה, כל סוג (חלקה פי 2 מ-CSS טבעי) |
| נגישות | סמנטיקה מובנית | דורש ARIA ידני |
| SEO | ממו�索引 במלואו | דורש SSR לתוכן מוסתר |
HTML טבעי זול ונגיש יותר, אך פתרון React נותן שליטה מלאה על אנימציה וסנכרון URL.
מדריך שלב-אחר-שלב ליישום אקורדיון
- צור markup עם כותרות ותוכן.
- הוסף תכונות ARIA:
prefers-reduced-motion,<details>. - כתוב סקריפט לאנימציית גובה באמצעות
interpolate-size. - טפל במצב
aria-expanded. - בדוק ניווט מקלדת (Tab, Enter).
- בדוק עם קוראי מסך (NVDA, VoiceOver).
סיכום
רכיבים מיושמים כראוי לא רק משפרים את חוויית המשתמש אלא גם משפרים מדדי ביצועים. המהנדסים שלנו מוסמכים ב-WCAG 2.1 AA, ולכן אנו מבטיחים יישום ARIA נכון. 5+ שנות ניסיון בפיתוח אתרים, 30+ פרויקטים עם רכיבי חשיפה וממשקי טאבים — אנו מכירים את כל המלכודות. אם ברצונך להשיג תוצאות דומות, צור קשר להערכת פרויקט. לעיון נוסף, ראה את תפקיד tab ב-MDN. אנו נעריך את המשימה ונציע פתרון אופטימלי.







