CLS на SEO-лендинге Next.js 15: shadcn/ui и стабильный макет
Пользователь читает оффер на лендинге. В момент клика по кнопке «Оставить заявку» блок съезжает вниз — палец попадает в ссылку «Политика конфиденциальности». Раздражение, отказ от формы, потерянная заявка. Google фиксирует Cumulative Layout Shift (CLS) выше 0,1 и помечает URL в Search Console. LCP и INP могут быть в зелёной зоне, но Page Experience остаётся неполным: три метрики Core Web Vitals работают вместе.
На лендингах с shadcn/ui и Radix UI сдвиги часто незаметны в dev на быстром Wi‑Fi, но воспроизводятся на мобильном 4G: toast внизу экрана, dropdown, подгрузка аватара в отзывах, баннер cookie consent. Partial Prerendering и JSON-LD не компенсируют макет, который «прыгает» после гидрации.
В этой статье — узкий production-подход для Next.js 15 App Router: резервирование размеров, next/font с adjustFontFallback, паттерны shadcn/ui без layout shift, embed и динамический контент через Suspense. Цель — CLS ≤ 0,1 в полевых данных CrUX на мобильных.
Почему CLS критичен для SEO-лендинга
CLS суммирует неожиданные сдвиги видимого контента за время жизни страницы (до unload или скрытия вкладки). Сдвиг без пользовательского ввода в течение 500 мс после предыдущего сдвига увеличивает score. Google использует 75-й перцентиль CrUX по URL.
| Порог | CLS | Типичная причина на лендинге |
|---|---|---|
| Good | ≤ 0,1 | Зарезервированы размеры медиа, шрифтов, UI-блоков |
| Needs improvement | 0,1–0,25 | Баннер cookies, lazy images без height |
| Poor | > 0,25 | Web-шрифт без fallback, реклама, виджет чата |
Связь с SEO: CLS входит в Core Web Vitals и Page Experience. На конкурентных коммерческих запросах страница с «Good» по всем трём метрикам стабильнее удерживает позиции. Отдельно CLS влияет на конверсию: сдвиг CTA в момент тапа — прямой убыток, не абстрактный «Performance score».
Отличие от LCP и INP: LCP — «когда появилось главное», INP — «как быстро откликнулся интерфейс», CLS — «остаётся ли макет на месте». На лендинге услуг с формой заявки CLS часто важнее INP, если форма статична, а сдвигают cookie-баннер и подгружаемые отзывы.
Источники сдвигов: shadcn/ui, Radix и Tailwind
Компоненты shadcn/ui — копируемый код на Radix Primitives. Они доступны и семантичны, но без дисциплины размеров создают сдвиги.
Toast и Sonner
<Toaster /> в layout.tsx монтируется после гидрации. Если контейнер не position: fixed с изолированным stacking context, появление toast сдвигает footer. Решение: Sonner с position="bottom-right", toastOptions без изменения min-height body, Toaster вне потока документа (fixed, pointer-events-none на обёртке где нужно).
// app/layout.tsx
import { Toaster } from '@/components/ui/sonner';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
{children}
<Toaster position="bottom-right" richColors closeButton />
</body>
</html>
);
}
Dialog, Sheet, Popover
Модальные окна Radix блокируют scroll через overflow: hidden на body — полоса прокрутки исчезает, контент сдвигается на ~15 px. Паттерн для лендинга:
/* globals.css — компенсация scrollbar gutter */
html {
scrollbar-gutter: stable;
}
В Next.js 15 подключите в app/globals.css. На macOS с overlay scrollbar эффект слабее, на Windows — заметен.
Skeleton и Card без min-height
Блок отзывов с Card без min-height: после загрузки API текст длиннее placeholder — карточки растут, сдвигают CTA. Задайте min-h-[280px] или skeleton той же высоты, что и финальный контент.
Avatar и Image в testimonials
// ❌ Avatar без размера — CLS при загрузке src
<Avatar>
<AvatarImage src={review.avatarUrl} />
<AvatarFallback>АК</AvatarFallback>
</Avatar>
// ✅ Явные размеры (shadcn по умолчанию h-10 w-10 — проверьте, что Image внутри не ломает)
<Avatar className="h-10 w-10 shrink-0">
<AvatarImage src={review.avatarUrl} alt="" />
<AvatarFallback>АК</AvatarFallback>
</Avatar>
Для списка логотипов клиентов используйте фиксированную сетку grid-cols-4 gap-4 и aspect-square на ячейке, а не flex без высоты строки.
Accordion FAQ
Radix Accordion анимирует height. Если начальное состояние «все закрыты», а после гидрации открывается пункт из URL hash — сдвиг. На SEO-лендинге держите FAQ полностью в Server Component без client-only initial state; defaultValue задавайте статически, не из useSearchParams на первом рендере без совпадения SSR/HTML.
Если нужна подобная разработка — напишите в Telegram.
next/font, line-height и резерв под текст
Смена web-шрифта на системный fallback с другими метриками — классический CLS на текстовом hero. В связке с LCP-статьёй: next/font решает обе метрики.
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin', 'cyrillic'],
display: 'swap',
variable: '--font-inter',
adjustFontFallback: true,
preload: true,
weight: ['400', '600', '700'],
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" className={inter.variable}>
<body className="font-sans">{children}</body>
</html>
);
}
adjustFontFallback: true— подгоняет Arial/системный fallback под метрики Inter, снижает сдвиг при swap.- Ограничьте веса — каждое начертание — отдельный файл; лишние веса увеличивают вероятность «позднего» swap на медленной сети.
Зарезервированная высота для hero и h1
Если h1 в две строки на mobile и одну на desktop, не меняйте line-height между breakpoints без min-height на обёртке. Паттерн:
<h1 className="min-h-[2.5em] text-4xl font-bold leading-tight md:min-h-[1.2em] md:text-5xl">
SEO-лендинг на Next.js 15 под ключ
</h1>
Подберите min-h по макету в Figma — грубая оценка лучше, чем отсутствие резерва.
Tailwind size-* для иконок Lucide
Иконки в кнопках shadcn Button должны иметь фиксированный box: className="size-4 shrink-0". Иначе SVG без размеров схлопывается до 0×0 и «вырастает» после CSS — микросдвиги в каждой кнопке суммируются.
Медиа, embed и сторонние виджеты
next/image и aspect-ratio
Для любого изображения above-the-fold или в блоке отзывов:
<div className="relative aspect-video w-full overflow-hidden rounded-lg">
<Image src="/case-study.webp" alt="Кейс" fill className="object-cover" sizes="(max-width: 768px) 100vw, 640px" />
</div>
fill без aspect-* на родителе — частая причина CLS: контейнер 0 px до загрузки.
iframe: карты, видео, Cal.com
Embed без width/height сдвигает секцию «Контакты». Резервируйте:
<div className="relative aspect-[16/9] w-full max-w-2xl">
<iframe
title="Запись на консультацию"
src="https://cal.com/embed/..."
className="absolute inset-0 h-full w-full border-0"
loading="lazy"
/>
</div>
Cookie consent и аналитика
Баннер снизу (fixed bottom-0) не должен вставляться в поток перед footer. Подключайте через portal или отдельный client island с position: fixed и z-50. Google Tag Manager и Metrika — strategy="afterInteractive" в next/script, не синхронный скрипт в <head>.
Виджет чата (Intercom, Crisp)
Плавающая кнопка справа снизу: задайте width/height placeholder в CSS до загрузки SDK, иначе кнопка «выпрыгивает» поверх CTA. На лендинге с жёстким CLS-бюджетом откладывайте чат до requestIdleCallback или первого scroll.
Динамический контент, PPR и Suspense
Next.js 15 Partial Prerendering отдаёт статический shell. Если в shell — skeleton 200 px, а streamed chunk — блок 600 px, CLS неизбежен.
Правила:
- Skeleton ≈ финальный layout — те же
grid,gap,min-height. - Не стримите above-the-fold без размеров: hero, h1, primary CTA — в static shell.
loading.tsxдля route segment должен повторять структуру страницы, не generic spinner на весь viewport.
// app/(marketing)/reviews/loading.tsx
export default function ReviewsLoading() {
return (
<section className="grid min-h-[320px] gap-6 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-[280px] animate-pulse rounded-lg bg-muted" />
))}
</section>
);
}
framer-motion и CLS
Анимация opacity не вызывает CLS; анимация height, margin, y на layout-affecting свойствах — вызывает. Для hero используйте opacity + transform (композитный слой), не height: auto анимации. Подробнее про INP — в отдельной статье серии; здесь правило: не двигайте элементы, занимающие место в потоке, без резерва.
A/B и персонализация
Vercel Flags или GrowthBook: вариант B с другим заголовком в две строки вместо одной — CLS при гидрации. Резервируйте min-height под максимальный вариант или рендерите вариант на сервере до HTML (edge config), не client-only swap после paint.
Замеры, чеклист и регрессии
Инструменты
- Search Console → Core Web Vitals → CLS по группам URL.
- Chrome DevTools → Performance → Experience → Layout Shifts (список элементов-виновников).
- Lighthouse → Avoid large layout shifts — конкретные DOM-узлы.
@vercel/speed-insights— полевой CLS после деплоя.
Чеклист перед релизом
-
scrollbar-gutter: stableв globals.css. - Все
next/imageи<img>с известными размерами илиaspect-*родителем. -
next/fontсadjustFontFallback, без@importшрифтов в CSS. - Toaster/баннеры/cookie —
fixed, вне потока. - Skeleton совпадает с финальной высотой секций.
- Нет client-only изменения layout above-the-fold на первом paint.
- Embed и iframe с aspect-ratio контейнером.
Типичные регрессии
| Изменение | Эффект на CLS |
|---|---|
| Добавили промо-полосу сверху без резерва | Сдвиг всего контента вниз |
Web font через Google Fonts <link> | Swap без adjustFontFallback |
| «Упростили» skeleton до спиннера | Streamed content сдвигает fold |
| shadcn Select в форме без фикс. высоты label | Сдвиг при открытии (меньше, но в форме критично) |
Нужна помощь? Telegram → или vic.kell@ya.ru
FAQ
CLS 0,08 в Lighthouse, но Poor в Search Console — почему?
Lighthouse — lab на эмулированном устройстве. CrUX — реальные пользователи, медленные сети, другие viewport. Ориентируйтесь на Search Console; Lighthouse — для отладки конкретных сдвигов.
Нужен ли size-adjust вручную при next/font?
Обычно нет: adjustFontFallback: true генерирует fallback с близкими метриками. Ручной @font-face с size-adjust — для кастомных шрифтов вне next/font.
Radix Dialog всё равно сдвигает страницу при открытии
Проверьте scrollbar-gutter: stable и что Dialog не меняет padding body дважды (конфликт с другим modal). Для лендинга рассмотрите Sheet только на mobile вместо full Dialog — меньше манипуляций с body.
Влияет ли View Transitions (Astro/Next experimental) на CLS?
Переходы между страницами могут давать кратковременные сдвиги, если не зафиксированы общие элементы (header). На Next.js 15 experimental View Transitions тестируйте в CrUX после включения.
Cookie banner обязателен по GDPR — как не убить CLS?
position: fixed; inset-x: 0; bottom: 0 без вставки в document flow. Высота баннера фиксирована (h-24 или min-h), контент страницы не переразмечивается при появлении.
shadcn/ui Carousel с автопрокруткой?
Autoplay меняет высоту слайдов, если слайды разной длины — задайте одинаковый min-height на CarouselItem или обрезку line-clamp.
Заключение
CLS на SEO-лендинге Next.js 15 — это дисциплина резервирования: каждый элемент, который появляется или меняет размер после первого paint, должен либо занимать место заранее, либо жить в fixed слое вне потока. shadcn/ui не виноват сам по себе — виноваты toast без позиционирования, Accordion без SSR-согласованности, Avatar без размеров и cookie-баннер в потоке footer.
В связке с оптимизацией LCP (hero, шрифты) и INP (минимальный client JS) стабильный макет закрывает третью метрику Core Web Vitals. CLS ≤ 0,1 на mobile CrUX для лендинга услуг в 2026 году — достижимая цель за один спринт верстки, если замеры идут из DevTools Layout Shift, а не только из зелёного Lighthouse на MacBook.