javascript

JavaScript: заметка об Anchor Positioning API

  • пятница, 9 января 2026 г. в 00:00:05
https://habr.com/ru/companies/timeweb/articles/979180/

Привет, друзья!

В этой небольшой статье мы вместе с вами немного пощупаем новый 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-канале