JavaScript: заметка об Anchor Positioning API
- пятница, 9 января 2026 г. в 00:00:05
Привет, друзья!
В этой небольшой статье мы вместе с вами немного пощупаем новый Web API - Anchor Positioning.
Anchor Positioning API предоставляет новые возможности для связывания элементов между собой. Одни элементы являются якорями (якорными, anchor elements), другие - позиционируемыми относительно якорей (закрепленными, anchor-positioned elements). Размер и положение позиционируемого элемента может определяться размером и положением якорного элемента.
Кроме того, с помощью CSS можно:
определять альтернативные позиции закрепленного элемента, которые будут применяться браузером, например, при выходе такого элемента за пределы экрана
определять условия видимости закрепленного элемента, например, скрывать такой элемент при выходе за пределы экрана
На сегодняшний день этот API поддерживается всеми основными браузерами (в Safari пока только в качестве экспериментальной возможности):

При подготовке данной заметки я нашел эту замечательную статью, в которой исчерпывающе излагаются почти все возможности Anchor Positioning API. Не вижу смысла ее дублировать. Лучше рассмотрим парочку практических примеров.
Допустим, я хочу реализовать выпадающее меню (дропдаун, dropdown). Меню должно прикрепляться к кнопке и отображаться при ее нажатии. В терминах Anchor Positioning API кнопка - это якорь, а меню - закрепленный элемент:
<div class="container">
<!-- Якорь -->
<button id="dropdownTrigger">Dropdown trigger</button>
<!-- Закрепленный элемент -->
<div id="dropdownMenu">
<button class="dropdownItem">Item 1</button>
<button class="dropdownItem">Item 2</button>
<button class="dropdownItem">Item 3</button>
</div>
</div>
Связывание якоря и позиционируемого элемента можно реализовать с помощью HTML, что может быть удобным при большом количестве таких связок или при разработке повторно используемого компонента в каком-либо фреймворке, например, React. Мы сделаем это в CSS. Для этого нужно определить anchor-name в якорном элементе и position-anchor в закрепленном элементе:
#dropdownTrigger {
anchor-name: --dropdownTrigger;
}
#dropdownMenu {
position-anchor: --dropdownTrigger;
/* absolute или fixed */
position: absolute;
}
И... ничего не работает. Потому что необходимо также определить сторону якоря, к которой прикрепляется позиционируемый элемент:
#dropdownMenu {
position-anchor: --dropdownTrigger;
/* Снизу */
position-area: bottom;
position: absolute;
}
Теперь работает, но есть нюанс. Даже два. Во-первых, хорошая идея - позволить браузеру автоматически инвертировать сторону прикрепления при переполнении позиционируемым элементом содержимого родительского элемента. В нашем случае, таким предком является <body>:
body {
margin: 40px;
height: calc(100vh - 80px);
border: 2px dashed;
box-sizing: border-box;
overflow: hidden;
position: relative;
}
Во-вторых, я хочу, чтобы меню совпадало по ширине с якорем. Для этого можно использовать функцию anchor-size. Итоговые стили меню:
#dropdownMenu {
--p: 5px;
position-anchor: --dropdownTrigger;
/* см. ниже */
position-area: bottom;
position-try-fallbacks: flip-block;
/* var(--p) * 2 - padding, 2px - outline */
width: calc(anchor-size(width) - var(--p) * 2 - 2px);
position: absolute;
/* см. ниже */
margin-top: var(--p);
padding: var(--p);
outline: 1px solid rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
gap: var(--p);
/* см. ниже */
opacity: 0;
visibility: hidden;
transition: opacity 250ms ease-in-out, visibility 250ms ease-in-out;
}
Что интересно, position-try-fallbacks: flip-block при выходе закрепленного элемента за пределы нижней части body поменяет не только position-area: bottom на position-area: top, но также margin-top на margin-bottom. Удобно, не правда ли?
По умолчанию меню скрыто. Сделаем так, чтобы оно было видимым при любой фокусировке на кнопке или программной фокусировке (с помощью клавиатуры) на элементе меню:
.container {
&:where(:has(#dropdownTrigger:focus), :has(.dropdownItem:focus-visible))
#dropdownMenu {
opacity: 1;
visibility: visible;
}
}
Наконец, напишем немного кода для реализации перетаскивания, чтобы увидеть position-try-fallbacks в действии:
const body = document.querySelector('body')
const anchor = document.getElementById('dropdownTrigger')
let isDragging = false
let offsetX, offsetY
anchor.addEventListener('mousedown', (e) => {
isDragging = true
// Вычисляем отступы между курсором и верхним левым углом элемента
const rect = anchor.getBoundingClientRect()
offsetX = e.clientX - rect.left
offsetY = e.clientY - rect.top
anchor.style.cursor = 'grabbing'
})
document.addEventListener('mousemove', (e) => {
if (!isDragging) return
const rect = body.getBoundingClientRect()
anchor.style.left = e.clientX - offsetX - rect.left + 'px'
anchor.style.top = e.clientY - offsetY - rect.top + 'px'
})
document.addEventListener('mouseup', () => {
isDragging = false
anchor.style.cursor = 'move'
})
Если вы знаете, с чем связано сплющивание кнопки (и меню) при достижении правой части
body, поделитесь в комментариях (у меня было несколько предположений, но они не оправдались).

В целом, разметка и стили нашего тултипа аналогичны разметке и стилям нашего дропдауна:
<div class="container">
<div id="anchor">Anchor</div>
<div id="tooltip">Some long description</div>
</div>
body {
margin: 40px;
height: calc(100vh - 80px);
border: 2px dashed;
box-sizing: border-box;
overflow: hidden;
position: relative;
}
.container {
&:hover #tooltip {
opacity: 1;
visibility: visible;
}
}
#anchor {
anchor-name: --anchor;
position: absolute;
top: 25%;
left: 25%;
cursor: move;
background: #ddd;
border-radius: 3px;
padding: 5px;
user-select: none;
}
#tooltip {
--d: 8px;
position-anchor: --anchor;
position-area: bottom;
position-try-fallbacks: flip-block;
position: absolute;
z-index: 1;
margin-top: var(--d);
border-radius: 3px;
padding: 5px;
background: black;
color: white;
opacity: 0;
visibility: hidden;
transition: opacity 250ms ease-in-out, visibility 250ms ease-in-out;
/* Стрелка */
&::before {
content: '';
position: absolute;
width: var(--d);
height: var(--d);
background: inherit;
left: 50%;
top: 0;
transform: rotate(45deg) translateX(-50%);
}
}
Код перетаскивания также аналогичный.
Проблема номер раз: при достижении боковой части body стрелка тултипа остается на месте.

Хотелось бы, чтобы она следовала за серединой якоря.
Эту проблему можно решить с помощью функции anchor, которая позволяет определять положение закрепленного элемента на основе положения якоря. В нашем случае, это будет выглядеть так:
&::before {
left: calc(anchor(--anchor center) - var(--d) / 2);
}
Но этого недостаточно. Для того, чтобы все работало, как ожидается, нам нужно также определить anchor-name для тултипа, изменить свойство position стрелки с absolute на fixed и определить вертикальное положение стрелки с помощью anchor():
#tooltip {
anchor-name: --tooltip;
/* Стрелка */
&::before {
position: fixed;
left: calc(anchor(--anchor center) - var(--d) / 2);
top: calc(anchor(--tooltip top) - var(--d) / 2);
/* translateX(-50%) больше не нужен */
transform: rotate(45deg);
}
}
Проблема номер два: при инвертировании браузером положения тултипа по вертикали, стрелка не инвертируется.

Все, что нам требуется, это изменить top на bottom в значении свойства top стрелки, т.е.:
top: calc(anchor(--tooltip bottom) - var(--d) / 2);
Казалось бы, что решение кроется в @position-try:
#tooltip {
position-try-fallbacks: --customTop;
}
@position-try --customTop {
/* При position-try-fallbacks: flip-block эти стили применяются автоматически */
position-area: top;
margin-bottom: var(--d);
&::before {
top: calc(anchor(--tooltip bottom) - var(--d) / 2);
}
}
Но, к сожалению, на сегодняшний день это не работает, поскольку внутри @position-try можно использовать только ограниченный набор свойств, а селекторы, вложенные правила, псевдоэлементы и т.п. - нельзя.
А что если определить переменную в тултипе и переопределять ее в @position-try?
#tooltip {
--arrow: calc(anchor(--tooltip top) - var(--d) / 2);
&::before {
top: var(--arrow);
}
}
@position-try --customTop {
position-area: top;
margin-bottom: var(--d);
--arrow: calc(anchor(--tooltip bottom) - var(--d) / 2);
}
Тоже не работает.
В процессе поиска решения я наткнулся на эту замечательную статью.
Статья содержит подробное объяснение процесса решения. Суть в следующем:
стрелка представляет собой многоугольник:
clip-path: polygon(
50% 0.2em,
100% var(--d),
100% calc(100% - var(--d)),
50% calc(100% - 0.2em),
0 calc(100% - var(--d)),
0 var(--d)
);
по умолчанию многоугольник имеет высоту, равную высоте тултипа + высота стрелки (--d) * 2:
top: calc(anchor(--tooltip top) - var(--d));
bottom: calc(anchor(--tooltip bottom) - var(--d));

в тултипе определяется отступ снизу, равный размеру стрелки (--d), стрелка наследует отступы тултипа:
#tooltip {
margin-bottom: var(--d);
}
#tooltip::before {
margin: inherit;
}
это приводит к тому, что нижняя часть многоугольника скрывается за тултипом:

наконец, при срабатывании position-try-fallbacks: flip-block браузер меняет margin-top: var(--d) на margin-bottom: var(--d), это наследуется стрелкой, нижняя часть многоугольника становится видимой, а верхняя скрывается:

Очень хитрый «хак», согласитесь?
Возможно, существуют и другие «костыли». Но неужели не существует «стандартного» решения? Гуглим дальше и находим эту статью.
Совсем недавно Chrome и Edge начали поддерживать anchored container queries (запросы к якорному контейнеру?). Эти запросы позволяют решить нашу проблему следующим образом:
делаем тултип anchored query container с помощью container-type: anchored
определяем директиву @container anchored()
#tooltip {
/* Нам больше не нужен --customTop */
position-try-fallbacks: flip-block;
/* Делаем тултип anchored query container */
container-type: anchored;
}
@container anchored(fallback: flip-block) {
#tooltip::before {
top: calc(anchor(--tooltip bottom) - var(--d) / 2);
}
}
На мой вкус, синтаксис слегка многословен, но он соответствует другим запросам к контейнеру, например, запросам к размерам (@container (min-width: 1024px)) или стилям (@container style(--isDark: true)) контейнера. По всей видимости, примерно в таком виде он и будет стандартизирован.
Это все, чем я хотел поделиться с вами в этой небольшой заметке. Надеюсь, вы узнали что-то новое и, следовательно, не зря потратили время.
Happy coding!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩