Акордеон і таби: як не провалити Core Web Vitals та доступність
Ми часто стикаємося з проєктами, де акордеони та таби реалізовані без урахування продуктивності та доступності. Типові проблеми: анімація висоти на height: auto викликає Layout Shift, контент прихованих панелей не індексується, а відсутність ARIA-атрибутів робить компоненти недоступними для скрінрідерів. Результат — низький LCP, провалений аудит Lighthouse, втрата до 30% трафіку з мобільних пристроїв. Наш досвід показує, що правильна реалізація цих компонентів «під ключ» займає від 2 до 3 днів і включає: серверний рендер, URL-синхронізацію, Schema.org розмітку та повну підтримку WCAG 2.1 AA. Нижче — конкретні рішення, які ми застосовуємо в продакшені.
Проблеми, які вирішуємо
Як уникнути CLS при анімації акордеона?
Використання height: auto в CSS transitions призводить до перерахунку layout. Рішення — JavaScript з вимірюванням scrollHeight і встановленням фіксованого значення. Ми застосовуємо цей підхід уже в 30+ проєктах. Альтернатива — CSS grid-template-rows: 0fr / 1fr з overflow: hidden. Але JS-версія забезпечує плавну анімацію в усіх браузерах, включаючи старі Safari.
Чому контент у табах може не індексуватися?
Google індексує вміст з display: none, але надає йому меншу вагу. Для SEO-критичного контенту використовуємо visibility: hidden + position: absolute та серверний рендер усіх панелей. Порівняйте: при клієнтському приховуванні контент отримує вагу 0.3 від відкритого, а при SSR — повну вагу. В одному з проєктів це збільшило видимість сторінки на 25% за даними Яндекс.Вебмастера.
Як зробити таби доступними для клавіатури?
Реалізуємо патерн role="tablist" з ArrowLeft/Right, Home/End. Управління фокусом відбувається через tabIndex. Без цього компонент не проходить WCAG 2.1 AA.
Як ми це робимо: стек і кейс
Використовуємо React 18, Next.js 14, TypeScript, Tailwind. Для анімації — CSS transitions з 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-синхронізацію через useSearchParams та Schema.org розмітку для FAQ. Приклад табів з keyboard navigation:
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 використовуємо force-dynamic на сторінці з табами або рендеримо всі панелі в HTML, приховуючи їх через CSS (не через display:none, а через position:absolute; visibility:hidden; height:0; overflow:hidden).
Додатковий приклад: акордеон з анімацією на 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 години | Keyboard, скрінрідер, perftest |
| Деплой та документація | 2-4 години | Git merge, readme, навчання |
Строки та що входить
| Компонент | Строк |
|---|---|
| Простий акордеон (нативний) | 2-3 години |
| React акордеон з анімацією | 4-6 годин |
| Система табів з URL і Schema | 1-1.5 дня |
| Повний комплект (акордеон+таби) | 2-3 дні |
Відзначимо: Що входить в роботу:
- Вихідний код компонентів (TypeScript, CSS modules або Tailwind)
- Документація по API та ARIA-атрибутам
- Інтеграція в існуючий проєкт (Git)
- Навчання команди (1 година)
- Підтримка протягом 2 тижнів після деплою
Типові помилки
- Використання
overflow: hiddenдля анімації висоти — викликає CLS. Краще JS. - Забути про
prefers-reduced-motion— вимикаємо анімацію. - Не перевіряти стан завантаження — гідратований компонент може "моргнути".
Порівняння підходів
| Критерій | Нативний HTML (<details>) |
React-компонент (JS-анімація) |
|---|---|---|
| Час розробки | 1 година | 4-6 годин |
| Анімація | Тільки CSS (без interpolate-size не працює) |
Плавна, будь-яка |
| Доступність | Вбудована семантика | Потрібна ARIA вручну |
| SEO | Індексується повністю | Вимагає SSR для прихованого контенту |
Нативний HTML дешевший і доступніший, але React-рішення дає повний контроль над анімацією та URL-синхронізацією.
Покрокова інструкція з реалізації акордеона
- Створіть розмітку з заголовками та контентом.
- Додайте ARIA-атрибути:
aria-expanded,aria-controls. - Напишіть скрипт для анімації висоти через
scrollHeight. - Обробіть стан
prefers-reduced-motion. - Перевірте клавіатурну навігацію (Tab, Enter).
- Протестуйте в скрінрідерах (NVDA, VoiceOver).
Висновок
Правильно реалізовані акордеони та таби — це не лише зручність користувачів, але й покращення метрик продуктивності. Наші інженери сертифіковані за WCAG 2.1 AA, тому гарантуємо правильну реалізацію ARIA. 5+ років досвіду у веб-розробці, 30+ проєктів з акордеонами та табами — ми знаємо всі підводні камені. Якщо ви хочете отримати аналогічний результат — зв'яжіться з нами для оцінки вашого проєкту. Для отримання консультації та оцінки – пишіть нам. Ми оцінимо задачу та запропонуємо оптимальне рішення. В середньому економія на підтримці є значною, а окупність швидкою.







