CLS на лендинге Next.js 15: shadcn/ui без сдвигов — KEL IT
Сайты 9 мин чтения

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 improvement0,1–0,25Баннер cookies, lazy images без height
Poor> 0,25Web-шрифт без 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>

Баннер снизу (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 неизбежен.

Правила:

  1. Skeleton ≈ финальный layout — те же grid, gap, min-height.
  2. Не стримите above-the-fold без размеров: hero, h1, primary CTA — в static shell.
  3. 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.

Замеры, чеклист и регрессии

Инструменты

  1. Search Console → Core Web Vitals → CLS по группам URL.
  2. Chrome DevTools → Performance → Experience → Layout Shifts (список элементов-виновников).
  3. Lighthouse → Avoid large layout shifts — конкретные DOM-узлы.
  4. @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 после включения.

position: fixed; inset-x: 0; bottom: 0 без вставки в document flow. Высота баннера фиксирована (h-24 или min-h), контент страницы не переразмечивается при появлении.

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.

KEL IT

Нужна разработка под ключ?

Я занимаюсь такими проектами профессионально. Telegram-боты, Mini Apps, сайты, мобильные и десктопные приложения. Расскажите о задаче — разберём и предложим решение.