Перейти к содержимому

Позиционирование и слои: absolute, sticky, z-index и stacking contexts

Позиционирование — способ вытащить элемент из обычного потока и положить его точно туда, куда нужно: поверх карточки, в угол вьюпорта, поверх всего приложения. Это механизм тултипов, модалок, выпадашек, бейджей «99+». И это же механизм, который генерирует 90% вопросов «почему мой z-index не работает?» — потому что z-index живёт не в глобальной шкале, а внутри stacking context’ов, которые создаются чуть ли не каждым вторым свойством.

В краткой версии ты копипастил position: absolute; top: 0; left: 0. Здесь разбираем, относительно ЧЕГО считается top: 0, почему fixed внутри модалки с transform вдруг начал позиционироваться от неё, и как устроена лестница слоёв, по которой на самом деле ползут элементы.

Значение В потоке? Смещение Слой/z-index Типичное применение
static да не работает (top/left игнорируются) нет дефолт всего
relative да (место сохраняется) относительно своей обычной позиции работает, если задан точка отсчёта для absolute, мелкие сдвиги
absolute нет относительно containing block работает тултипы, бейджи, оверлеи
fixed нет относительно вьюпорта работает шапки, плавающие кнопки
sticky гибрид «прилипает» внутри родителя работает sticky-шапки таблиц, якорные меню

relative смещает элемент визуально, но физическое место в потоке остаётся за ним — соседи его «не замечают»:

поток до смещения: .box { position: relative; top: 20px; left: 20px }
┌─────────┐┌─────────┐ ┌─────────┐ ┌─────────┐
│ А ││ Б │ │ А │ │ Б │
└─────────┘└─────────┘ └─────────┘ └─────────┘
«дырка» ┌─────────┐
│ Б │ ← визуально тут
└─────────┘
(место Б в потоке ПУСТОЕ, соседи не двигаются)

Главная роль relative — быть точкой отсчёта: ближайший предок с position: relative/absolute/fixed/sticky становится containing block для absolute-потомка. Поэтому паттерн всегда парный: «родитель relative — ребёнок absolute».

Absolute-элемент ищет ближайшего предка с НЕ-static позиционированием. Нашёл — считает от его padding-края. Не нашёл — от края initial containing block (по сути, вьюпорта/корневого блока документа). Правила поиска containing block формализованы в CSS Positioned Layout Module Level 3:

<div class="card"> ← position: relative
текст карточки
<div class="badge">99+</div> ← position: absolute; top: -8px; right: -8px
</div>
┌─ padding-край .card ───────────────┐
│ текст карточки │
│ ┌───────┤
│ │badge │ ← top/right считаются ОТ КРАЯ .card
│ │ 99+ │ (а не от вьюпорта!)
└────────────────────────────┘───────┘
▲ смещён на -8px за край карточки
.card { position: relative; }
.badge {
position: absolute;
top: -8px; /* от верхнего padding-края .card ВВЕРХ (за карточку) */
right: -8px;
}

Свойства top/right/bottom/left для absolute элемента с ОБЕИМИ парами (например, left: 0; right: 0) растягивают его на всю ширину containing block — это используется для «шторок» и плашек.

fixed позиционируется от viewport’а и не скроллится. Классика: «наверх» кнопка, закреплённая шапка. НО: если между элементом и вьюпортом оказался предок с transform, filter, perspective, backdrop-filter или will-change, содержащий их, — fixed молча становится absolute-подобным: его containing block — этот предок, и он скроллится вместе со страницей.

<div style="transform: translateX(0)"> ← modal-обёртка с transform
<button style="position: fixed; bottom: 20px">OK</button>
</div>
ОЖИДАНИЕ: РЕАЛЬНОСТЬ:
┌──────────────────────────┐ ┌───────────────╥──────────┐
│ │ │ страница ║ transform│
│ страница │ │ ║ -контейнер│
│ │ │ ┌───────┐ ║ (CB!) │
│ ┌───┐ │ │ │button │ ║ │
│ │OK │ │ скроллит │ └───────┘ ║ │
│ └───┘ │ с viewport │ ▲ прилип к ВНУТРЕННЕМУ │
└──────────────────────────┘ │ краю transform-предка │
▲ кнопка всегда тут и ЕДЕТ со скроллом

Это главная грабля порталов-модалок в React: модалка рисуется внутри узла с анимационным transform, а её кнопки position: fixed едут по странице. Решение — рендерить оверлей в body (порталы) или не анимировать обёртку transform’ом. Поведение fixed (и его исключения) документировано в MDN: position.

sticky — гибрид: элемент ведёт себя как relative, пока скролл не доведёт его до заданного порога (top: 0), после чего «прилипает» как fixed — но только внутри своего родителя:

.toc {
position: sticky;
top: 1rem; /* прилипнуть, когда верх элемента дойдёт до 1rem от верха вьюпорта */
}
скролл страницы вниз:
┌────────────────────────┐ состояние 1: элемент в потоке (как relative)
│ обычный контент │
│ ┌──────────┐ │
│ │ .toc │ │
│ └──────────┘ │
├────────────────────────┤ состояние 2: top достигнут → ПРИЛИП
│ скролл… ┌──────┼──┐
│ │ .toc │ │ ← висит на top: 1rem
│ скролл… └──────┘ │
│ │
├────────────────────────┘──┘ состояние 3: родитель закончился → отлипает
│ родитель закончился и уезжает вверх вместе с ним

Условия работы sticky, при нарушении которых он молча деградирует в relative:

  1. Родитель не имеет overflow: hidden/auto/scroll (иначе прилипает внутри скролл-контейнера — часто это и нужно, но вопреки ожиданиям).
  2. У элемента задан хотя бы один порог (top/left/right/bottom) — без них sticky не к чему «прилипать».
  3. Родитель выше элемента: если родитель == высоте элемента, двигаться негде.

z-index сравнивается только ВНУТРИ одного stacking context. Контекст — изолированная «лестница», победитель которой выходит наружу как единый слой. Поэтому «z-index: 9999 не помогает»: контекст дропдауна ниже контекста модалки, и внутри своей лестницы он побеждает, но сама лестница стоит ниже. Полный перечень условий создания контекста и порядок отрисовки слоёв — в MDN: Stacking context.

Стекинг-контекст создаётся:

Условие Пример
Корень документа <html> всегда контекст
position + z-index ≠ auto relative/absolute/fixed/sticky с числовым z-index
opacity < 1 даже opacity: 0.99 создаёт контекст!
transform, filter, perspective, clip-path любые значения, не none
will-change с этими свойствами will-change: transform
isolation: isolate осознанное создание контекста
mix-blend-mode ≠ normal
flex/grid-айтем с z-index ≠ auto даже без position!
Структура слоёв (упрощённо):
┌─ <html> (контекст корня) ──────────────────────────────┐
│ │
│ слой фона/бордеров корня │
│ …negative z-index элементы (внутри своих контекстов) │
│ …блочные элементы потока (обычный контент) │
│ …float'ы │
│ …инлайн-контент │
│ …position: z-index: auto/0 (в порядке DOM) │
│ ┌─ контекст №1 (opacity: 0.9 у .modal) ────────────┐ │
│ │ внутри — своя лестница: │ │
│ │ z-index: 1 кнопка > z-index: 5 хедер │ │
│ └──────────────────────────────────────────────────┘ │ ← целиком выше
│ ┌─ контекст №2 (transform у .dropdown-wrapper) ─────┐ │ любого z-index
│ │ внутри: dropdown z-index: 1000 │ │ внутри контекста №1,
│ └──────────────────────────────────────────────────┘ │ потому что №2 стоит
│ │ НИЖЕ №1 в корне
└────────────────────────────────────────────────────────┘
правило: z-index сравнивается ТОЛЬКО между братьями внутри одного контекста

Лечение вечной проблемы «модалка не перекрывает шапку»: не повышать z-index до миллиона, а поднять модалку в DOM-иерархии (в конец body) и держать оба элемента в корневом контексте — там z-index и сравнивается честно.

isolation: isolate — явное создание контекста на элементе: защита «моего» внутреннего z-index от внешнего мира. Стандарт приёма: на каждый сложный виджет — isolation: isolate, чтобы его внутренние слои не влезали в чужие лестницы.

Значение Поведение Применение
visible дефолт, всё торчит наружу
hidden обрезает, скролла нет маски, обрезка декора
scroll обрезает + всегда полоса таблицы, требующие скролла явно
auto обрезает + полоса по необходимости универсальный скролл-контейнер
clip обрезает без скролл-контейнера (совр.) строгая обрезка, не создаёт BFC

Важная связка: overflowvisible на предке обрезает sticky и ломает fixed (см. выше), а также создаёт блокировку для абсолютных потомков — торчащий тултип обрежется родителем с overflow: hidden. Семантика каждого значения — в MDN: overflow.

.overlay {
position: absolute; /* или fixed */
inset: 0; /* top/right/bottom/left = 0: растянуть на весь CB */
margin: auto; /* + явный размер → центрирование */
width: fit-content;
height: fit-content;
}

Или через transform без явных размеров:

.center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* сдвиг на половину СВОИХ размеров */
}

Первый вариант не требует transform — и не плодит stacking context от transform.

.field { position: relative; display: inline-block; }
.field__tooltip {
position: absolute;
bottom: calc(100% + 8px); /* на 8px выше верха родителя */
left: 50%;
transform: translateX(-50%); /* центрируем по горизонтали */
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.field:hover .field__tooltip,
.field:focus-within .field__tooltip {
opacity: 1;
}

focus-within — важно: тултип открывается и с клавиатуры, не только по hover.

<div class="modal" role="dialog" aria-modal="true">
<div class="modal__backdrop"></div>
<div class="modal__window">
<h2>Заголовок</h2>
<button class="modal__close" aria-label="Закрыть">✕</button>
</div>
</div>
.modal { position: fixed; inset: 0; } /* весь вьюпорт */
.modal__backdrop {
position: absolute; inset: 0;
background: rgb(0 0 0 / 0.5);
}
.modal__window {
position: absolute;
inset: 0;
margin: auto;
width: min(480px, calc(100% - 2rem));
height: fit-content; /* центрирование по обеим осям */
}

Модалка рисуется в конце body — чтобы её stacking context был среди последних в корне. Если проект на React — это как раз работа порталов: createPortal(<Modal/>, document.body).

Грабля: меню внутри карточки с overflow: hidden обрезается. Решение — рендерить выпадашку отдельно, рядом с триггером, и позиционировать её в координатах триггера (JS выставляет --x/--y, CSS применяет):

.dropdown {
position: absolute;
top: var(--y);
left: var(--x);
}

Это ровно то, что делают Radix и все зрелые библиотеки (popper/floating-ui): портал + измерение позиции триггера.

  1. «z-index: 9999 не работает». Смотри путь до корня: элемент сидит внутри чужого stacking context (transform, opacity, filter предка) — его z-index сравнивается только с братьями. Решение: поднять элемент в DOM или дать предку isolation: isolate и сравнить контексты на одном уровне.
  2. fixed внутри transform-предка. Кнопка «вверх» начала ездить со скроллом — найди предка с transform/filter. Решение: портал в body.
  3. absolute «летает от вьюпорта» вместо карточки. Забыт position: relative на родителе — absolute нашёл более высокого предка или ушёл к корню. Пары «relative-родитель / absolute-ребёнок» — всегда.
  4. sticky не прилипает. Проверь три условия: задан порог (top), нет overflow: hidden/auto/scroll на предке, родитель выше элемента. Любое нарушение — деградация в relative без ошибок в консоли.
  5. Тултип обрезался родителем. У предка overflow: hidden (часто для «скруглённых углов» через border-radius + overflow). Решение: портал/вынесение тултипа из обрезанной зоны.
  6. Сдвиг relative «дырявит» поток. Визуально элемент уехал, место осталось — макет «дышит» пустотой. Для декоративных сдвигов — transform, relative оставь для точки отсчёта.
  7. inset и старые браузеры. inset: 0 — эквивалент четырёх свойств; поддержка с 2021 (Chrome 87). В legacy — расписывай четыре строки.
  1. Что такое containing block для absolute-элемента? Ближайший предок с позиционированием не-static (или с transform/filter/perspective для fixed). Координаты и размеры считаются от его padding-краёв; если такого нет — от initial containing block (вьюпорт документа).
  2. Почему z-index: 9999 может не поднимать элемент поверх? Потому что z-index действует внутри stacking context: предок с transform/opacity/filter образует свой контекст, внутри которого элемент побеждает, но сам контекст стоит ниже соседнего. Сравнение — только между элементами одного контекста.
  3. Что создаёт stacking context? Корень; position + z-index ≠ auto; opacity < 1; transform, filter, perspective, clip-path; will-change с ними; mix-blend-mode; isolation: isolate; flex/grid-айтем с z-index ≠ auto.
  4. Почему position: fixed «ломается» внутри модалки с transform? Любой предок с transform/filter/perspective становится containing block для fixed вместо вьюпорта — элемент скроллится вместе с документом и позиционируется от предка. Решение — портал в body.
  5. Условия работы sticky? Задан порог (top/left/right/bottom), предок без overflow, меняющего scroll-контекст, и высота родителя больше высоты элемента. Нарушение любого — тихая деградация в relative.
  6. Как отцентрировать абсолютный элемент неизвестного размера? inset: 0; margin: auto (нужен способ задать размер — хотя бы fit-content) или top: 50%; left: 50%; transform: translate(-50%, -50%).
  7. Чем relative сдвиг отличается от transform-сдвига? Relative оставляет «дыру» в потоке и не создаёт stacking context сам по себе; transform сдвигает визуально без дыры, но создаёт stacking context (и становится containing block’ом для fixed).
  8. Как сделать тултип доступным с клавиатуры? Открытие по :hover И :focus-within на триггере, роль tooltip/aria-describedby у связи, вынесение из overflow-зон, pointer-events: none у самого тултипа, чтобы он не перехватывал события.
  1. Бейдж на карточке. Карточка 240×300 с картинкой; бейдж «HOT» в правом верхнем углу, слегка заступающий за край. Критерий: позиция считается от карточки при любом положении карточки в потоке; бейдж не влияет на раскладку.
  2. Sticky-меню. Лендинг: шапка, затем блок меню, который прилипает к верху при скролле и отлипает в конце секции. Критерий: прилипание работает; сделай контрольный сломанный вариант с overflow: hidden на обёртке и объясни разницу.
  3. Модалка. Модальное окно с бэкдропом, затемнением, центрированием и крестиком. Затем заверни её в контейнер с transform: rotate(0) и покажи, как «поехала» кнопка position: fixed. Критерий: оба поведения воспроизведены и объяснены.
  4. Война z-index. Шапка z-index: 100, внутри неё выпадашка z-index: 50; модалка z-index: 90. Сделай так, чтобы выпадашка была видна НАД модалкой при открытой модалке — двумя способами (DOM-иерархией и изоляцией). Критерий: понимаешь, почему один из способов — плохая идея (выпадашка должна быть под модалкой по UX).
  5. Тултип. Поле ввода с тултипом сверху: появляется по hover и по фокусу, не обрезается, не перехватывает клики. Критерий: работает с клавиатуры (Tab → тултип виден), читается скринридером как описание поля.