Липкие заголовки и полновысотные элементы: Сложная комбинация

alexei09/01/2025 - 09:00
Липкие заголовки и полновысотные элементы: Сложная комбинация

Автор Филип Браунен (Philip Braunen). Перевод статьи "Sticky Headers And Full-Height Elements: A Tricky Combination".

Липкое (sticky) позиционирование - одна из тех функций CSS, которая довольно чувствительна и может быть сведена на нет множеством факторов. И вот один из этих факторов, который стоит добавить в ваш мысленный каталог: липкие элементы плохо работают, если им приходится вместе с другими элементами формировать общую высоту, например, 100vh. Филип Браунен объясняет, почему это происходит, и предлагает решение.

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

Кажется, это должно быть довольно просто, не так ли? Я был уверен (читай: самоуверен), что решение этой задачи займёт всего пару минут, но оказалось, что она гораздо сложнее, чем я предполагал.

Прежде чем приступить к работе, давайте взглянем на разметку и CSS, чтобы понять, с чем мы имеем дело:


<body>
  <header class="header">Header Content</header>
  <section class="hero">Hero Content</section>
  <main class="main">Main Content</main>
</body>


.header {
  position: sticky;
  top: 0; /* Смещение, иначе липкость работать не будет! */
}

/* другие стили */

В соответствии с этими декларациями элемент .header будет отображаться в верхней части страницы. При этом элемент .hero под ним остается без изменений. Это то, что нам нужно поменять.

Низко висящий плод

Первое, что приходит в голову, - это поместить липкий заголовок и информационный блок в какой-нибудь родительский контейнер и задать этому контейнеру высоту 100vh, чтобы он занимал весь экран. После этого мы могли бы использовать Flexbox для распределения дочерних элементов и сделать так, чтобы информационный блок занимал оставшееся пространство.


<body>
  <div class="container">
    <header class="header">Header Content</header>
    <section class="hero">Hero Content</section>
  </div>
  <main class="main">Main Content</main>
</body>


.container {
  height: 100vh;
  display: flex;
  flex-direction: column;
}

.hero {
  flex-grow: 1;
}

/* другие стили */

На первый взгляд всё выглядит правильно, но посмотрите, что происходит при прокрутке через информационный блок:

Липкий заголовок застревает в родительском контейнере! Но почему?

Такое поведение кажется нелогичным, по крайней мере поначалу. Возможно, вы слышали, что состояние sticky - это комбинация состояний relative и fixed, то есть элемент с таким позиционированием участвует в обычном потоке документа, но только до тех пор, пока при прокрутке не достигнет границ своего родительского контейнера, после чего он приобретает состояние fixed. Хотя рассматривание элемента с состоянием sticky как комбинацию других значений может быть полезным мнемоническим приёмом, оно не учитывает одно важное различие между элементами sticky и fixed:

Элемент со свойством position: fixed не зависит от родительского элемента, в который он вложен, или от любого из своих предков. Он выйдет из обычного потока документа и расположится непосредственно за пределами области просмотра, как будто приклеенный на определённом расстоянии от края экрана.

И наоборот, элемент со свойством position: sticky будет перемещаться вместе с краями области просмотра (или ближайшего контейнера с прокруткой), но он никогда не выйдет за границы своего непосредственного родительского элемента. Ну, по крайней мере, если не считать его визуальное трансформирование. Так что лучше думать об этом в том ключе, что position: sticky - это, в некотором роде, локально ограниченный position: fixed. Это намеренное дизайнерское решение, позволяющее использовать липкие заголовки для конкретных разделов, как в алфавитных списках в мобильных интерфейсах.

Итак, это не подходит для решения нашей проблемы. Нам нужно найти решение, которое не требует создания контейнера вокруг заголовка.

Исправлено, но не решено

Может быть, мы сможем немного упростить себе жизнь. Что если, вместо контейнера, мы зададим элементу .header фиксированную высоту, скажем, 150px? Тогда всё, что нам нужно сделать, это задать высоту элемента .hero как height: calc(100vh – 150px).

Этот подход вроде как работает, но недостатки более коварны, чем в нашей предыдущей попытке, потому что они могут быть не сразу заметны. Вы, вероятно, заметили, что заголовок слишком высокий, и нам нужно провести расчёты, чтобы выбрать более подходящую высоту.

Давайте немного поразмышляем о будущем:

  • Что, если потомкам элемента .header нужно будет сворачиваться или перестраиваться при разных размерах экрана или расти, чтобы сохранять читаемость на мобильных устройствах?
  • Что если JavaScript будет манипулировать содержимым элемента?

Всё это может незначительно изменить идеальный размер элемента .header, и поиск правильных значений высоты для каждого сценария может превратиться в кошмар с точки зрения обслуживания из-за неуправляемых точек останова и магических чисел - особенно если учесть, что это нужно делать не только для элемента .header, но и для элемента .hero, который от него зависит.

Я бы сказал, что этот обходной путь тоже кажется неправильным. Фиксированная высота нарушает одно из основных преимуществ CSS-макета - автоматическое изменение размеров элементов в зависимости от их содержимого, и отказ от этого обычно делает нашу жизнь сложнее, а не проще.

Таким образом, нам придется рассмотреть...

Новый подход

Теперь, когда мы разобрались с ограничениями, с которыми придется иметь дело, другой способ сформулировать проблему заключается в том, что мы хотим, чтобы элементы .header и .hero совместно занимали высоту в 100vh без явного изменения размера этих элементов или упаковки их в контейнер. В идеале, мы должны найти что-то, что уже имеет высоту 100vh, и применить это. Именно здесь меня осенило, что свойство display: grid может оказаться тем, что нам нужно!

Давайте попробуем так: мы объявляем display: grid для элемента body и добавляем элемент-рзделитель перед элементом .header, которому мы назначим класс .above-the-fold-spacer. Этот новый элемент будет иметь высоту 100vh и занимать всю ширину сетки. Затем мы укажем нашему разделителю, что он должен занимать две строки сетки, и привяжем его к верхней части страницы.

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


<body>
  <!-- Разделитель обеспечивает нужную высоту -->
  <div class="above-the-fold-spacer"></div>

  <!-- Эти два элемента помещаются поверх разделителя -->
  <header class="header">Header Content</header>
  <section class="hero">Hero Content</section>

  <!-- Остальная часть страницы остается без изменений -->
  <main class="main">Main Content</main>
</body>


body {
  display: grid;
}

.above-the-fold-spacer {
  height: 100vh;
  /* Охватываем с первой до последней колонки сетки */
  /* (Отрицательное число отсчитывает с конца сетки) */
  grid-column: 1 / -1;
  /* Начать с первой линии сетки и занять 2 строки */
  grid-row: 1 / span 2; 
}

/* etc. */

Это и есть волшебный ингредиент.

Добавив разделитель, мы создали две строки сетки, которые в высоту вместе занимают ровно 100vh. Теперь, по сути, нам осталось только указать элементам .header и .hero выровняться по этим существующим строкам. Нам нужно указать им, чтобы они начинались с той же строки сетки, что и элемент .above-the-fold-spacer, чтобы они не пытались встать рядом с ним. Когда же мы это сделаем… вуаля!

Причина, по которой это работает, заключается в том, что сетка-контейнер может иметь несколько дочерних элементов, занимающих одну и ту же ячейку и накладывающихся друг на друга. В такой ситуации самый высокий дочерний элемент определяет общую высоту строки сетки - или, в данном случае, общую высоту двух строк (100vh).

Чтобы контролировать, как именно два видимых элемента распределяют доступное пространство между собой, мы можем использовать свойство grid-template-rows. Я сделал так, чтобы в первой строке использовалось значение min-content, а не 1fr. Это необходимо для того, чтобы элемент .header не занимал столько же места, сколько элемент .hero, а брал только то, что ему нужно, оставляя остальное для информационного элемента.

Вот наше решение полностью:


body {
  display: grid;
  grid-template-rows: min-content 1fr;
}

.above-the-fold-spacer {
  height: 100vh;
  grid-column: 1 / -1;
  grid-row: 1 / span 2;
}

.header {
  position: sticky;
  top: 0;
  grid-column-start: 1;
  grid-row-start: 1;
}

.hero {
  grid-column-start: 1;
  grid-row-start: 2;
}

И вуаля: липкий заголовок произвольного размера над информационным элементом, который увеличивается, чтобы заполнить оставшееся видимое пространство!

Предостережение и заключительные соображения

Стоит отметить, что здесь важен порядок элементов в HTML документе. Если мы определим элемент .above-the-fold-spacer после элемента .hero, он будет перекрывать и блокировать доступ к элементам под ним. Мы можем обойти это, объявив order: -1 или z-index: -1 или visibility: hidden.

Также, имейте в виду, что это простой пример. Если вы, например, добавите боковую панель слева от страницы, вам нужно будет настроить, с какого столбца будут начинаться элементы. Тем не менее, в большинстве случаев использование CSS-сетки, скорее всего, будет менее хлопотным, чем сизифов труд по ручному управлению и согласованию значений высоты нескольких элементов.

Ещё одним преимуществом такого подхода является его адаптируемость. Если вы решите, что группа из трёх элементов должна занимать всю высоту экрана, а не две его части, то вы можете определить, чтобы невидимый разделитель охватывал три строки и назначить видимые элементы в соответствующую строку. Даже если содержимое информационного элемента .hero приведёт к тому, что его высота превысит 100vh, сетка адаптируется ничего не сломав. Этот подход хорошо поддерживается во всех современных браузерах.

Чем больше я думаю об этой технике, тем больше убеждаюсь, что на самом деле она довольно проста и понятна. С другой стороны, всем известно, как юристы могут убедить себя в собственных аргументах? Вполне возможно, что существует ещё более простое решение, которое я упустил из виду.