Позиционирование и слои: absolute, sticky, z-index и stacking contexts
Позиционирование — способ вытащить элемент из обычного потока и положить его точно туда, куда нужно: поверх карточки, в угол вьюпорта, поверх всего приложения. Это механизм тултипов, модалок, выпадашек, бейджей «99+». И это же механизм, который генерирует 90% вопросов «почему мой z-index не работает?» — потому что z-index живёт не в глобальной шкале, а внутри stacking context’ов, которые создаются чуть ли не каждым вторым свойством.
В краткой версии ты копипастил position: absolute; top: 0; left: 0. Здесь разбираем, относительно ЧЕГО считается top: 0, почему fixed внутри модалки с transform вдруг начал позиционироваться от неё, и как устроена лестница слоёв, по которой на самом деле ползут элементы.
position: пять режимов
Заголовок раздела «position: пять режимов»| Значение | В потоке? | Смещение | Слой/z-index | Типичное применение |
|---|---|---|---|---|
static |
да | не работает (top/left игнорируются) |
нет | дефолт всего |
relative |
да (место сохраняется) | относительно своей обычной позиции | работает, если задан | точка отсчёта для absolute, мелкие сдвиги |
absolute |
нет | относительно containing block | работает | тултипы, бейджи, оверлеи |
fixed |
нет | относительно вьюпорта | работает | шапки, плавающие кнопки |
sticky |
гибрид | «прилипает» внутри родителя | работает | sticky-шапки таблиц, якорные меню |
relative: сдвиг без изменения потока
Заголовок раздела «relative: сдвиг без изменения потока»relative смещает элемент визуально, но физическое место в потоке остаётся за ним — соседи его «не замечают»:
поток до смещения: .box { position: relative; top: 20px; left: 20px }
┌─────────┐┌─────────┐ ┌─────────┐ ┌─────────┐│ А ││ Б │ │ А │ │ Б │└─────────┘└─────────┘ └─────────┘ └─────────┘ «дырка» ┌─────────┐ │ Б │ ← визуально тут └─────────┘ (место Б в потоке ПУСТОЕ, соседи не двигаются)Главная роль relative — быть точкой отсчёта: ближайший предок с position: relative/absolute/fixed/sticky становится containing block для absolute-потомка. Поэтому паттерн всегда парный: «родитель relative — ребёнок absolute».
absolute: containing block решает всё
Заголовок раздела «absolute: containing block решает всё»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: относительно вьюпорта… обычно
Заголовок раздела «fixed: относительно вьюпорта… обычно»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: прилипание с условиями
Заголовок раздела «sticky: прилипание с условиями»sticky — гибрид: элемент ведёт себя как relative, пока скролл не доведёт его до заданного порога (top: 0), после чего «прилипает» как fixed — но только внутри своего родителя:
.toc { position: sticky; top: 1rem; /* прилипнуть, когда верх элемента дойдёт до 1rem от верха вьюпорта */}скролл страницы вниз:
┌────────────────────────┐ состояние 1: элемент в потоке (как relative)│ обычный контент ││ ┌──────────┐ ││ │ .toc │ ││ └──────────┘ │├────────────────────────┤ состояние 2: top достигнут → ПРИЛИП│ скролл… ┌──────┼──┐│ │ .toc │ │ ← висит на top: 1rem│ скролл… └──────┘ ││ │├────────────────────────┘──┘ состояние 3: родитель закончился → отлипает│ родитель закончился и уезжает вверх вместе с нимУсловия работы sticky, при нарушении которых он молча деградирует в relative:
- Родитель не имеет
overflow: hidden/auto/scroll(иначе прилипает внутри скролл-контейнера — часто это и нужно, но вопреки ожиданиям). - У элемента задан хотя бы один порог (
top/left/right/bottom) — без них sticky не к чему «прилипать». - Родитель выше элемента: если родитель == высоте элемента, двигаться негде.
z-index и stacking contexts: лестница слоёв
Заголовок раздела «z-index и stacking contexts: лестница слоёв»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, чтобы его внутренние слои не влезали в чужие лестницы.
overflow: видимость и обрезка
Заголовок раздела «overflow: видимость и обрезка»| Значение | Поведение | Применение |
|---|---|---|
visible |
дефолт, всё торчит наружу | — |
hidden |
обрезает, скролла нет | маски, обрезка декора |
scroll |
обрезает + всегда полоса | таблицы, требующие скролла явно |
auto |
обрезает + полоса по необходимости | универсальный скролл-контейнер |
clip |
обрезает без скролл-контейнера (совр.) | строгая обрезка, не создаёт BFC |
Важная связка: overflow ≠ visible на предке обрезает sticky и ломает fixed (см. выше), а также создаёт блокировку для абсолютных потомков — торчащий тултип обрежется родителем с overflow: hidden. Семантика каждого значения — в MDN: overflow.
Типовые задачи
Заголовок раздела «Типовые задачи»Центрирование absolute-элемента
Заголовок раздела «Центрирование absolute-элемента».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).
Выпадашка (dropdown), не обрезаемая родителем
Заголовок раздела «Выпадашка (dropdown), не обрезаемая родителем»Грабля: меню внутри карточки с overflow: hidden обрезается. Решение — рендерить выпадашку отдельно, рядом с триггером, и позиционировать её в координатах триггера (JS выставляет --x/--y, CSS применяет):
.dropdown { position: absolute; top: var(--y); left: var(--x);}Это ровно то, что делают Radix и все зрелые библиотеки (popper/floating-ui): портал + измерение позиции триггера.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- «z-index: 9999 не работает». Смотри путь до корня: элемент сидит внутри чужого stacking context (transform, opacity, filter предка) — его z-index сравнивается только с братьями. Решение: поднять элемент в DOM или дать предку
isolation: isolateи сравнить контексты на одном уровне. - fixed внутри transform-предка. Кнопка «вверх» начала ездить со скроллом — найди предка с transform/filter. Решение: портал в body.
- absolute «летает от вьюпорта» вместо карточки. Забыт
position: relativeна родителе — absolute нашёл более высокого предка или ушёл к корню. Пары «relative-родитель / absolute-ребёнок» — всегда. - sticky не прилипает. Проверь три условия: задан порог (top), нет
overflow: hidden/auto/scrollна предке, родитель выше элемента. Любое нарушение — деградация в relative без ошибок в консоли. - Тултип обрезался родителем. У предка
overflow: hidden(часто для «скруглённых углов» через border-radius + overflow). Решение: портал/вынесение тултипа из обрезанной зоны. - Сдвиг relative «дырявит» поток. Визуально элемент уехал, место осталось — макет «дышит» пустотой. Для декоративных сдвигов — transform, relative оставь для точки отсчёта.
insetи старые браузеры.inset: 0— эквивалент четырёх свойств; поддержка с 2021 (Chrome 87). В legacy — расписывай четыре строки.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что такое containing block для absolute-элемента? Ближайший предок с позиционированием не-static (или с transform/filter/perspective для fixed). Координаты и размеры считаются от его padding-краёв; если такого нет — от initial containing block (вьюпорт документа).
- Почему z-index: 9999 может не поднимать элемент поверх? Потому что z-index действует внутри stacking context: предок с transform/opacity/filter образует свой контекст, внутри которого элемент побеждает, но сам контекст стоит ниже соседнего. Сравнение — только между элементами одного контекста.
- Что создаёт 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.
- Почему position: fixed «ломается» внутри модалки с transform? Любой предок с transform/filter/perspective становится containing block для fixed вместо вьюпорта — элемент скроллится вместе с документом и позиционируется от предка. Решение — портал в body.
- Условия работы sticky? Задан порог (top/left/right/bottom), предок без overflow, меняющего scroll-контекст, и высота родителя больше высоты элемента. Нарушение любого — тихая деградация в relative.
- Как отцентрировать абсолютный элемент неизвестного размера?
inset: 0; margin: auto(нужен способ задать размер — хотя бы fit-content) илиtop: 50%; left: 50%; transform: translate(-50%, -50%). - Чем relative сдвиг отличается от transform-сдвига? Relative оставляет «дыру» в потоке и не создаёт stacking context сам по себе; transform сдвигает визуально без дыры, но создаёт stacking context (и становится containing block’ом для fixed).
- Как сделать тултип доступным с клавиатуры?
Открытие по
:hoverИ:focus-withinна триггере, рольtooltip/aria-describedbyу связи, вынесение из overflow-зон, pointer-events: none у самого тултипа, чтобы он не перехватывал события.
Практика
Заголовок раздела «Практика»- Бейдж на карточке. Карточка 240×300 с картинкой; бейдж «HOT» в правом верхнем углу, слегка заступающий за край. Критерий: позиция считается от карточки при любом положении карточки в потоке; бейдж не влияет на раскладку.
- Sticky-меню. Лендинг: шапка, затем блок меню, который прилипает к верху при скролле и отлипает в конце секции. Критерий: прилипание работает; сделай контрольный сломанный вариант с
overflow: hiddenна обёртке и объясни разницу. - Модалка. Модальное окно с бэкдропом, затемнением, центрированием и крестиком. Затем заверни её в контейнер с
transform: rotate(0)и покажи, как «поехала» кнопкаposition: fixed. Критерий: оба поведения воспроизведены и объяснены. - Война z-index. Шапка z-index: 100, внутри неё выпадашка z-index: 50; модалка z-index: 90. Сделай так, чтобы выпадашка была видна НАД модалкой при открытой модалке — двумя способами (DOM-иерархией и изоляцией). Критерий: понимаешь, почему один из способов — плохая идея (выпадашка должна быть под модалкой по UX).
- Тултип. Поле ввода с тултипом сверху: появляется по hover и по фокусу, не обрезается, не перехватывает клики. Критерий: работает с клавиатуры (Tab → тултип виден), читается скринридером как описание поля.
Что почитать
Заголовок раздела «Что почитать»- MDN: Позиционирование — все пять режимов с интерактивными примерами.
- MDN: Stacking context — полный список создателей контекстов и алгоритм отрисовки.
- CSS Tricks: position: sticky — нюансы sticky и его «неприлипания».
- web.dev: Слои и производительность — зачем браузеру stacking contexts и compositor-слои (мостик к главе про анимации).
- Philip Walton: Stacking Contexts интерактивно — классический разбор «z-index как работает на самом деле».