Акордеон і таби: як не провалити 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+ проєктів з акордеонами та табами — ми знаємо всі підводні камені. Якщо ви хочете отримати аналогічний результат — зв'яжіться з нами для оцінки вашого проєкту. Для отримання консультації та оцінки – пишіть нам. Ми оцінимо задачу та запропонуємо оптимальне рішення. В середньому економія на підтримці є значною, а окупність швидкою.







