Разбор 02

Ошибки адаптивной вёрстки на мобильных

На десктопе сайт выглядел отлично, а на телефоне блоки разъезжались, текст вылезал за экран, кнопки наползали друг на друга, а меню вообще не открывалось.

HTML CSS Flexbox адаптив viewport медиазапросы DevTools
Клиент
SaaS-стартап, лендинг тарифов
Стек
HTML, CSS (Flexbox), ванильный JS
Срок
1 день
Итог
Корректное отображение от 320px до 4K

Суть проблемы

Лендинг свёрстан «под десктоп» с фиксированной шириной 1200px. Вёрстку тестировали только на широком мониторе — и она там действительно выглядела идеально. Клиент открыл сайт с iPhone и увидел кашу: всё, что должно было подстраиваться под экран, оставалось «десктопным» и масштабировалось браузером целиком.

Мобильный экран — iPhone SEДо

Контент шире вьюпорта, появляется горизонтальный скролл ~840px, блоки разъезжаются.

Мобильный экран — iPhone SEПосле

Все блоки укладываются во вьюпорт, скролл строго вертикальный, таппоинты отзывчивы.

Как диагностировал

Сначала воспроизвёл проблему в контролируемой среде, затем пошёл от визуальных симптомов к корневым причинам — каждая находка объясняла сразу несколько симптомов.

  1. Шаг 1. Эмуляция вьюпортаОткрыл Chrome DevTools, включил Toggle Device Toolbar (iPhone 12 / SE). Сразу увидел горизонтальный скролл и общее overflow по оси X — верный признак фиксированных ширин без адаптивности.
  2. Шаг 2. Проверка viewport-метатегаВ <head> не оказалось <meta name="viewport">. Браузер на мобильном рендерил страницу как десктоп (шириной ~980px) и целиком масштабировал вниз — отсюда «мелкий неразборчивый десктоп» вместо адаптива.
  3. Шаг 3. Аудит CSS на фиксированные шириныНагуглил через DevTools проблемные правила: .container { width: 1200px } и карточки тарифов .pricing-card { width: 380px }. Жёсткие пиксели вместо %/fr/flex — прямая причина переполнения.
  4. Шаг 4. Анализ переполнения текстаПрошёлся по блокам с длинными URL и неразрывными строками. Ни overflow-wrap, ни word-break, ни hyphens не заданы — слово без пробелов растягивало строку за пределы контейнера.
  5. Шаг 5. Разбор перекрытия кнопокКарточки тарифов имели фиксированную height, а кнопка «Купить» стояла на position: absolute внутри них. При сжатии карточки контент налезал на кнопку, а flex-контейнер карточек не имел flex-wrap — элементы не переносились, а наслаивались.
  6. Шаг 6. Почему молчит бургерJS навешивал слушатель на document.getElementById('menu-toggle'), а в разметке menu-toggle был классом, не id — селектор возвращал null. Дополнительно шапка подгружалась через AJAX, и даже после правки селектора слушатель терялся при перерендере. Плюс медиазапросы шли в порядке max-width:1200px → 768px → 480px, и более раннее широкое правило перекрывало позднее узкое по специфичности.

Что исправил

Чинил от корневых причин: viewport, резиновые ширины, перенос текста, поток кнопок и делегирование событий для бургера. Каждое изменение объясняло один-два симптома из диагноза.

1. Viewport-метатег

Без него браузер рендерил десктопную раскладку и масштабировал её целиком. Добавил корректный метатег в <head>.

index.htmlбыло
<head>
  <meta charset="UTF-8">
  <title>Tariffs</title>
  <!-- viewport отсутствует -->
</head>
index.htmlстало
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, initial-scale=1">
  <title>Tariffs</title>
</head>

2. Резиновый контейнер вместо фиксированной ширины

Жёсткие 1200px переполняли любой экран уже при ~1000px. Заменил на max-width + width: 100% с боковыми отступами.

styles.cssбыло
.container {
  width: 1200px;
  margin: 0 auto;
}
styles.cssстало
.container {
  max-width: 1200px;
  width: 100%;
  margin: 0 auto;
  padding: 0 16px;
  box-sizing: border-box;
}

3. Карточки тарифов: flex вместо фиксированной ширины

Карточки 380px не помещались в ряд на мобильном и не переносились. Перевёл их на гибкую основу и включил перенос на родителе.

styles.cssбыло
.pricing-grid {
  display: flex;
  /* flex-wrap отсутствует — карточки наслаиваются */
}

.pricing-card {
  width: 380px;
  height: 420px;
  position: relative;
}

.pricing-card .buy-btn {
  position: absolute;
  bottom: 16px;
  left: 16px;
}
styles.cssстало
.pricing-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

.pricing-card {
  flex: 1 1 280px;
  /* height убран — карточка растёт по содержимому */
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.pricing-card .buy-btn {
  /* кнопка в нормальном потоке, не absolute */
  align-self: stretch;
  margin-top: auto;
}

4. Перенос длинных строк в тексте

Заголовки с длинными URL и неразрывными словами выходили за контейнер. Добавил свойства переноса для заголовков и основного текста.

styles.cssстало
h1, h2, h3, p, a, .pricing-card {
  overflow-wrap: break-word;
  word-break: break-word;
}

h1, h2, h3 {
  hyphens: auto;
}

5. Бургер-меню: делегирование событий вместо потерянного слушателя

Селектор искал id, которого не было, а шапка перерендеривалась через AJAX — слушатель терялся. Перевёл открытие на делегирование событий и data-атрибуты, чтобы клик работал независимо от порядка подгрузки разметки.

menu.jsбыло
// Селектор возвращает null: в разметке класс, не id
const toggle = document.getElementById('menu-toggle');

toggle.addEventListener('click', () => {
  document.querySelector('.nav-links')
    .classList.toggle('open');
});

// После AJAX-перерендера шапки toggle уже не в DOM
// — слушатель молча теряется, тап не работает
menu.jsстало
// Делегирование на document — работает и до, и после AJAX
document.addEventListener('click', (e) => {
  const btn = e.target.closest('[data-menu-toggle]');
  if (!btn) return;

  const menu = document.querySelector('[data-nav-links]');
  const isOpen = menu.classList.toggle('open');
  btn.setAttribute('aria-expanded', isOpen);
});

В разметке заменил класс/id на data-атрибуты: <button data-menu-toggle aria-expanded="false"> и <nav data-nav-links>.

6. Порядок медиазапросов

Перестроил медиазапросы от узкого к широкому, чтобы поздние специфичные правила не перекрывались ранними общими.

styles.cssбыло
/* Порядок «наоборот»: широкое правило выше узкого */
@media (max-width: 1200px) { /* ... */ }
@media (max-width: 768px)  { /* ... */ }
@media (max-width: 480px)  { /* ... */ }
styles.cssстало
/* От узкого к широкому — каждое правило уточняет предыдущее */
@media (max-width: 480px)  { /* mobile-first правки */ }
@media (max-width: 768px)  { /* планшет */ }
@media (max-width: 1200px) { /* десктоп */ }

Результат

После правок прошёлся по всем брейкпоинтам от 320px до 4K и прогнал PageSpeed Insights. Симптомы ушли на всех ширинах.

~840px → 0
0px
горизонтальный скролл
5 → 0
0
перекрытий элементов
1 → 0
0
таппоинтов без отклика
провал → 100
100
PageSpeed Mobile Usability

Сайт корректно отображается на iPhone и Android от 320px до 4K-десктопа: контейнер резиновый, карточки тарифов переносятся и не наслаиваются, текст переносится внутри блоков, бургер стабильно открывается благодаря делегированию событий. Адаптив теперь принят по умолчанию, а не как отдельный «мобильный» режим.

Корневые причины (отсутствие viewport, фиксированные ширины, абсолютное позиционирование кнопок, потерянный слушатель) устранены — не симптомы. Повторной регрессии на мобильных за последующий месяц не зафиксировано.
Есть похожая проблема?

Сначала найдём причину. Потом решим, что действительно нужно исправить.

Опишите симптом и что из-за него перестало работать. Я посмотрю, где искать причину и насколько задача похожа на точечный фикс.

Описать проблему ↗