Эта статья — перевод оригинальной статьи «Perfectly Pointed Tooltips: A Foundation», будут переводы ещё двух частей
Также я веду телеграм канал «Frontend по‑флотски», где рассказываю про интересные вещи из мира разработки интерфейсов.
Вступление
Тултипы — классика веб-разработки. Кликаешь по элементу — и рядом всплывает небольшой «бабл» с дополнительной информацией. Но за этим простым кликом почти всегда стоит JavaScript, который рассчитывает, где именно показать тултип.
Попробуем поставить его сверху. Нет, места не хватает. Окей, давай снизу. Теперь он упирается в правый край — сдвинем чуть левее. Приходится учитывать кучу нюансов, чтобы тултип оказался на своём месте и нигде не обрезался, не теряя важный текст.
В этой статье я покажу, как написать хороший JavaScript, который обработает все такие случаи…
Шучу! Мы обойдёмся CSS и посмотрим, как современный Anchor Positioning API может помочь со всем этим. Никакого тяжёлого JS и лишних проблем с производительностью.
На момент написания статьи все нужные нам фичи полностью поддерживаются только в Chrome и Edge.
Начнем с демонстрации:

Кликни по якорю и потяни его — посмотри, как ведёт себя тултип. Он старается расположиться так, чтобы оставаться видимым и не вылезать за границы. Круто, да? При этом для позиционирования тултипа вообще не используется JavaScript (кроме небольшого скрипта для перетаскивания якоря, но он к самому трюку не относится).
Всё это стало возможным благодаря новому Anchor Positioning API и паре других приёмов, которые мы сейчас разберём. Мы также посмотрим ещё несколько примеров, так что если ты впервые слышишь про anchor positioning — ты по адресу.
Начальная конфигурация
Начнём с разметки: элемент-якорь и его тултип.
<div id='anchor'></div>
<div id='tooltip'></div>
HTML сам по себе тут не особо интересный, но он показывает важную вещь: якорь и тултип — это два разных элемента, и совершенно не обязательно, чтобы один был родителем или потомком другого. Они могут находиться где угодно в DOM, а CSS всё свяжет между собой. Хотя на практике — и с точки зрения доступности — чаще всего лучше держать их рядом и явно связывать.
Конкретная HTML-структура будет зависеть от твоего случая и типа контента, так что выбирай её осознанно. Но в общем виде схема всегда одна: один элемент — якорь, другой — тултип.
Вот демо из другой статьи, где якорь — это бегунок (thumb) слайдера, а тултип — элемент <output>

CSS
#anchor {
anchor-name: --anchor;
}
#tooltip {
position: absolute;
position-anchor: --anchor;
position-area: top;
}
Мы задаём якорь с помощью свойства anchor-name, привязываем к нему тултип через position-anchor (и кастомный идентификатор — вот этот --anchor, который выглядит как кастомное свойство, но на самом деле это просто уникальное имя), а затем размещаем тултип сверху с помощью position-area.
Важно: тултип должен быть позиционирован абсолютно — сюда же относится и position: fixed.

Пока ничего хитрого: тултип всегда располагается сверху, независимо от того, где находится якорь. Можно перетаскивать якорь и смотреть, что получается.
В этой статье мы будем использовать простые значения для position-area, но вообще это свойство может быть довольно коварным.

Теперь, когда тултип на месте, давай добавим небольшой отступ снизу, чтобы подготовить место под «хвостик». Для этого достаточно использовать свойство bottom.
#anchor {
anchor-name: --anchor;
}
#tooltip {
--d: 1em; /* distance between tooltip and anchor */
position: absolute;
position-anchor: --anchor;
position-area: top;
bottom: var(--d);
}

Делаем позиционирование динамическим
Переходим к самому интересному — к тому, как сделать так, чтобы тултип автоматически подстраивал свою позицию, оставался видимым и не вылезал за границы. У Anchor Positioning для этого уже есть встроенные механизмы, нам нужно только разобраться, как их правильно использовать.
Первое, что важно понять, — это containing block (ограничивающий блок) абсолютно позиционированного элемента. Интуитивно может казаться, что логика тут про то, чтобы избежать выхода за пределы экрана, но это не так. Всё завязано именно на containing block. Если этот момент не понять, будет дико путать, так что давай разберёмся внимательнее.
Спецификация говорит следующее:
Anchor Positioning, хотя и мощный инструмент, может вести себя непредсказуемо. Якорь может быть где угодно на странице, поэтому позиционирование блока определённым образом может привести к тому, что он выйдет за границы своего containing block или частично окажется вне экрана.
Чтобы смягчить это, абсолютно позиционированный блок может использовать свойство
position-try-fallbacks, в котором перечисляются несколько вариантов набора свойств позиционирования/выравнивания. Браузер пробует их по очереди, и первый вариант, при котором блок не выходит за пределы своего containing block, считается подходящим.
Как видишь, всё крутится вокруг containing block, а не экрана.
Для абсолютно позиционированного элемента containing block — это первый предок, у которого position отличается от static (значение по умолчанию). Если такого предка нет, берётся начальный containing block.
В нашем примере я буду использовать body в качестве containing block и добавлю ему рамку и отступы со всех сторон, чтобы это было нагляднее.

Потяни якорь влево или вправо и посмотри, что происходит. Когда тултип упирается в границы, он перестаёт сдвигаться, даже если ты продолжаешь двигать якорь. Он начинает выходить за пределы body только тогда, когда сам якорь выходит за его границы.
Браузер изначально пытается разместить тултип сверху и по центру. Его первая задача — остаться внутри containing block, поэтому если места не хватает, чтобы сохранить выравнивание по центру, тултип смещается. Вторая задача — сохранять «привязку» к якорю, и в этом случае браузер уже позволяет тултипу выходить за пределы, если сам якорь оказывается снаружи.

Если считать, что якорь всегда остаётся внутри body, то у нас уже почти всё готово без лишних усилий. Тултип никогда не выйдет за пределы справа, слева или снизу. Остаётся только верх.
По умолчанию браузер может сдвигать элемент только внутри области, заданной через position-area, и не больше. Поэтому нам нужно подсказать браузеру, как вести себя в остальных случаях. Для этого мы используем position-try-fallbacks, где задаём несколько вариантов позиционирования, которые браузер будет «перебрать», если элемент не помещается в свой containing block.
Давай зададим позицию снизу:
position-try-fallbacks: bottom;
Потяни якорь вверх и посмотри, что получится:

Теперь, когда тултип вылезает за пределы body сверху, его позиция меняется на bottom. И он останется снизу, пока снова не начнёт выходить за границы — но уже снизу.
Другими словами: когда браузер выбирает новую позицию из-за переполнения, он держится за неё, пока не произойдёт новое переполнение.
Вот и всё — готово! Теперь наш тултип всегда оказывается на своём месте, где бы ни находился якорь.
Но теперь, когда тултип снизу, у нас больше нет зазора (для будущей стрелочки). Как это исправить?
Мы пока сказали браузеру лишь менять значение position-area на bottom, но можно сделать лучше, используя:
position-try-fallbacks: flip-block;
«Block» относится к блочной оси (в нашем случае — это вертикальная ось при стандартном направлении письма), и эта инструкция говорит: отзеркаль позицию по вертикальной оси. Логика в том, чтобы отразить исходную позицию на другую сторону. Чтобы это сделать, браузеру нужно поменять не только position-area, но и другие свойства.
В нашем примере мы задали position-area: top и bottom: var(--d).
Когда подключено position-try-fallbacks: flip-block, и происходит «переворот», это эквивалентно тому, как если бы мы написали: position-area: bottom и top: var(--d).
То есть зазор сохраняется!

Если ты сейчас немного потерялся и всё кажется запутанным — это нормально. Мы имеем дело с новыми механизмами, которые вообще не были привычны в мире CSS, так что нужно время, чтобы мозг к ним «привык».
Если кратко, у нас есть два варианта:
либо мы явно говорим браузеру: замени только
position-area, указав новую позицию;либо просим его «перевернуть» текущую позицию вдоль одной оси, и тогда браузер сам обновит сразу несколько свойств, не только
position-area.
Добавляем хвостик
Добавить хвостик к тултипу — дело довольно простое (у меня даже есть коллекция из сотни разных вариантов дизайна), но вот менять направление хвостика в зависимости от позиции тултипа — уже посложнее.

Сейчас Anchor Positioning не умеет сам по себе менять CSS в зависимости от того, где оказался элемент, но мы всё равно можем воспользоваться уже существующими возможностями и немного это «хаκнуть». А хаκи на CSS — это же весело ?
Я буду опираться на механизм «flip» и на то, что при перевороте могут меняться отступы — так мы и добьёмся нужного эффекта.
Для начала возьмём псевдо-элемент, который будет рисовать сам хвостик:
#tooltip {
--d: 1em; /* distance between tooltip and anchor */
--s: 1.2em; /* tail size */
}
#tooltip::before {
content: "";
position: absolute;
z-index: -1;
width: var(--s);
background: inherit;
inset: calc(-1*var(--d)) 0;
left: calc(50% - var(--s)/2);
clip-path: polygon(50% .2em,100% var(--d),100% calc(100% - var(--d)),50% calc(100% - .2em),0 calc(100% - var(--d)),0 var(--d));
}

По умолчанию оба хвостика видны. Нажми на «debug mode», чтобы лучше понять их форму и то, как они расположены.
Когда тултип сверху, нам нужно скрыть верхнюю часть хвоста. Для этого мы можем задать margin-top у псевдо-элемента, равный переменной --d. А когда тултип снизу — нам уже нужен margin-bottom.
Я задам отступ (margin) на самом элементе-тултипе, а затем унаследую его в псевдо-элементе.
#tooltip {
--d: 1em; /* distance between tooltip and anchor */
--s: 1.2em; /* tail size */
margin-top: var(--d);
}
#tooltip::before {
margin: inherit;
}

Та-дам! Теперь наш тултип идеален. За счёт margin одна сторона скрывается, и в каждый момент виден только один хвостик.
Но мы же не задавали
margin-bottom. Как тогда всё работает, когда тултип снизу?
Это как раз магия flip. Помнишь, мы уже делали похожий трюк с зазором: мы задали только top, а flip-block при перевороте сам превратил его в bottom? Здесь работает та же логика, только с отступами: margin-top автоматически становится margin-bottom, когда позиция переворачивается. Круто, да?
Важно понимать, что из-за использования margin тултип начнёт «переворачиваться» чуть раньше, потому что в расчёт берётся именно margin box (элемент вместе с внешними отступами). Но в нашем случае это даже плюс — позиция меняется заранее, ещё до того, как тултип визуально упрётся в край.
Двигаем хвостик
С верхом и низом мы разобрались, но остались случаи, когда тултип смещается, если якорь оказывается близко к левому или правому краю. Хвостик при этом тоже должен «ездить» за якорём. Чтобы этого добиться, нам нужно обновлять значение left и привязать его к позиции якоря.
Вместо этого:
left: calc(50% - var(--s));
Мы используем:
left: calc(anchor(--anchor center) - var(--s)/2);
Я заменяю 50%, которое ссылается на центр самого тултипа, на anchor(--anchor center), то есть на центр якорного элемента.
Функция anchor() — ещё одна классная фича Anchor Positioning. Она позволяет получить координату из любого anchor-элемента и использовать её, чтобы позиционировать абсолютно позиционированный элемент.

Упс — не сработало. Но я специально оставил этот вариант, потому что это важный учебный момент, который нужно разобрать.
Мы упираемся в одну из самых коварных проблем Anchor Positioning. В теории любой элемент на странице может быть якорем через anchor-name, а любой другой элемент — позиционироваться относительно этого якоря. В этом и смысл всей фичи. Но есть исключения, когда элемент не может сослаться на якорь.
Я не буду разбирать все случаи, но в нашем примере псевдо-элемент (хвостик) является потомком тултипа, который сам абсолютно позиционируется. В итоге тултип становится containing block для псевдо-элемента и не даёт ему «видеть» якорь, который объявлен снаружи.
(Если тебе казался сложным z-index и stacking context — держись, дальше будет похожий уровень ?)
Чтобы обойти это ограничение, я меняю позиционирование псевдо-элемента на position: fixed. Это меняет его containing block (теперь это viewport) и позволяет ему наконец «увидеть» якорь.

Да, сейчас демо работает некорректно, но потяни якорь ближе к краям — и увидишь, что хвостик по горизонтали уже располагается правильно, потому что теперь он «видит» якорный элемент.
Однако из-за того, что у псевдо-элемента теперь position: fixed, он больше не может позиционироваться относительно родителя — тултипа. Чтобы это исправить, мы можем сделать сам тултип тоже якорем, чтобы псевдо-элемент мог ссылаться уже и на него.
В итоге нам нужны два якоря: #anchor и #tooltip.
Тултип позиционируется относительно якоря, а хвостик — одновременно относительно якоря и тултипа.
#anchor {
position: absolute;
anchor-name: --anchor;
}
#tooltip {
--d: 1em; /* distance between anchor and tooltip */
--s: 1.2em; /* tail size */
position: absolute;
position-anchor: --anchor;
anchor-name: --tooltip;
}
/* the tail */
#tooltip:before {
content: "";
position: fixed;
z-index: -1;
width: var(--s);
/* vertical position from tooltip */
top: calc(anchor(--tooltip top ) - var(--d));
bottom: calc(anchor(--tooltip bottom) - var(--d));
/* horizontal position from anchor */
left: calc(anchor(--anchor center) - var(--s)/2);
}
Благодаря anchor() я могу получить координаты верхнего и нижнего краёв элемента-тултипа, а также центр якорного элемента — и с их помощью правильно позиционировать псевдо-элемент.

Теперь наш тултип действительно идеален! Как я уже говорил во вступлении, здесь нет ничего особенно сложного в CSS — мы использовали всего-то около двадцати деклараций.
А что, если мы хотим изначально разместить тултип снизу?
Легко! Просто меняем начальную конфигурацию так, чтобы изначальной была позиция снизу — и при переполнении flip-block автоматически переключит тултип наверх.
#tooltip {
position-area: bottom; /* instead of position-area: top; */
top: var(--d); /* instead of bottom: var(--d); */
margin-bottom: var(--d); /* margin-top: var(--d) */
}

Заключение
На этом с первой частью всё. Мы разобрались, как размещать тултип с помощью position-area и как задавать запасную позицию на случай переполнения. Плюс познакомились с механизмом flip и функцией anchor().
Во второй части (скоро!) мы усложним задачу и будем работать уже с более чем двумя позициями. Дай себе время переварить материал из этой части, прежде чем переходить дальше. И заодно очень рекомендую потратить пару минут на моё интерактивное демо для position-area, чтобы получше с ним подружиться.