Нативный <progress> — элемент-сирота: семантически правильный, доступный для скринридеров, но из коробки уродливый и без подписи. Если на полосе нужна подпись с актуальным процентом, обычно собирают свою конструкцию из <div>-обёртки, заливки и JS-скрипта, который синхронизирует число в тултипе с текущим значением.

В 2024-м появилась альтернатива: четыре относительно свежие CSS-фичи — @property, scroll-driven animations, anchor positioning и кастомизация псевдоэлементов <progress> — собираются в связку, которая читает значение прогресса прямо в CSS-переменную и рисует тултип-облачко рядом с концом заливки. Без JS, без обёрток, на одном теге.

Разберём приём по слоям: от голой стилизации <progress> до финального облачка, которое следует за бегунком.

Шаг 1. Стилизуем сам <progress>

Браузеры рисуют <progress> через внутренние псевдоэлементы, и у каждого движка они свои: в Chrome/Edge это ::-webkit-progress-bar для дорожки и ::-webkit-progress-value для заливки, в Firefox — ::-moz-progress-bar для заливки (дорожка стилизуется через сам корневой селектор). Чтобы свои стили вообще начали работать, родительскому элементу сначала надо снять системный вид через appearance: none.

.bar {
  appearance: none;
  width: 220px;
  height: 14px;
  border: none;
  border-radius: 999px;
  background: #e6e8ec; /* трек для Firefox */
}

.bar::-webkit-progress-bar {
  background: #e6e8ec;
  border-radius: 999px;
  overflow: hidden;
}

.bar::-webkit-progress-value {
  background: #0d9488;
  border-radius: 999px;
}

.bar::-moz-progress-bar {
  background: #0d9488;
  border-radius: 999px;
}

На разметке достаточно одного тега:

<progress class="bar" max="100" value="60"></progress>

Шаг 2. Достаём процент в CSS-переменную

Главная сложность дальше — передать число из атрибута value внутрь CSS, чтобы потом подставить его в текст тултипа. Прямого моста между HTML-атрибутом и числовой CSS-переменной долго не было; attr() с типизацией всё ещё экспериментален. Однако появился обход через scroll-driven animations.

Идея в одном предложении: ширина ::-webkit-progress-value внутри дорожки уже равна value / max от полной ширины — это сам браузер. Если повесить на этот псевдоэлемент view-timeline, а на родителе запустить анимацию вдоль этого таймлайна, прогресс таймлайна окажется численно завязан на величину заливки. Дальше дело техники: @keyframes переводит этот прогресс в кастомное свойство, и мы получаем число от 0 до 100, которое можно вывести через counter().

Сначала регистрируем переменную через @property — иначе анимировать кастомное свойство как целое число CSS не позволит:

@property --pct {
  syntax: '<integer>';
  inherits: true;
  initial-value: 100;
}

Теперь связываем таймлайн заливки с анимацией на родителе:

.bar {
  appearance: none;
  position: relative;
  animation: read-progress linear;
  animation-timeline: --bar-end;
  timeline-scope: --bar-end;
  animation-range: entry 100% exit 100%;
}

.bar::-webkit-progress-bar { overflow: auto; }

.bar::-webkit-progress-value {
  view-timeline: --bar-end inline;
}

@keyframes read-progress {
  to { --pct: 0; }
}

Несколько вещей, на которые стоит посмотреть внимательно:

  • overflow: auto у дорожки превращает её в scroll-контейнер — без этого scroll-таймлайн не оживёт, даже если реальной прокрутки внутри нет.
  • view-timeline: —bar-end inline на заливке объявляет таймлайн, чьим «субъектом» служит сам псевдоэлемент ::-webkit-progress-value. Чем уже заливка (меньше value), тем дальше она «выехала» за пределы дорожки — прогресс таймлайна растёт.
  • timeline-scope: —bar-end поднимает доступ к этому таймлайну на родителя — иначе родитель не видит таймлайнов своих псевдоэлементов.
  • animation-range: entry 100% exit 100% выбирает диапазон, при котором --pct = value в процентах: при value="60" на переменной осядет ровно 60. Математика тут опирается на то, как браузер считает прогресс view-таймлайна для элемента переменной ширины внутри scroll-контейнера — разбирать её построчно бессмысленно, проще убедиться на демо.

Шаг 3. Тултип-облачко через ::before

Когда --pct готова, остаётся вывести её в виде текста. CSS не умеет печатать значение переменной как текст напрямую, но умеет печатать счётчик — а счётчик можно сбросить в значение переменной:

.bar::before {
  content: counter(p) "%";
  counter-reset: p var(--pct);

  position: absolute;
  padding: 4px 8px;
  border-radius: 6px;
  background: #0d9488;
  color: #fff;
  font: 600 13px/1 system-ui, sans-serif;
}

Этого хватит, чтобы где-то рядом с прогрессом появилось облачко с текущим процентом. Но висит оно в одном статичном месте — над левым краем прогресса. Хочется, чтобы оно следовало за бегунком.

Шаг 4. Привязываем тултип к концу заливки

За такую привязку отвечает anchor positioning — механизм CSS, который позволяет одному элементу позиционироваться относительно якоря на другом. Якорь объявляем на заливке через anchor-name, в тултипе ссылаемся на него через position-anchor и указываем сторону через position-area:

.bar::-webkit-progress-value {
  anchor-name: --bar-end;
  view-timeline: --bar-end inline;
}

.bar::before {
  content: counter(p) "%";
  counter-reset: p var(--pct);

  position: absolute;
  position-anchor: --bar-end;
  position-area: top right;
  translate: -50% -10px;

  padding: 4px 8px;
  border-radius: 6px;
  background: #0d9488;
  color: #fff;
  font: 600 13px/1 system-ui, sans-serif;
}

position-area: top right прижимает облачко к правому краю якоря и поднимает над ним, а translate сдвигает его так, чтобы центр пузыря оказался ровно над концом заливки. Стоит поменять value — ширина заливки меняется, её правый край сдвигается, и тултип сдвигается вместе с ним, потому что он привязан к якорю на самой заливке.

На этом приём окончен: один тег <progress>, ноль JS, тултип с актуальным процентом всегда над концом заливки.

Где это сейчас работает

Связка опирается на четыре фичи разной зрелости. Самая древняя из них — @property, она доступна во всех живых браузерах больше года:

Поддержка браузерами
chrome
Chrome
85
firefox
Firefox
128
edge
Edge
85
safari
Safari
16.4
opera
Opera
71

Scroll-driven animations — ключевой кирпич всего приёма — пока живут только в Chromium и в свежем Safari, Firefox с поддержкой пока запаздывает:

Поддержка браузерами
chrome
Chrome
115
firefox
Firefox
 
edge
Edge
115
safari
Safari
26
opera
Opera
101

Anchor positioning подъехал последним — и в Firefox, и в Safari он появился совсем недавно:

Поддержка браузерами
chrome
Chrome
125
firefox
Firefox
147
edge
Edge
125
safari
Safari
26
opera
Opera
111

Итог: в текущем Chromium связка работает целиком, в свежем Safari — тоже, в Firefox пока отваливается scroll-driven часть, и тултип будет показывать дефолтное initial-value переменной --pct. Базовая стилизация прогресса (шаг 1) работает везде; всё остальное стоит подавать как progressive enhancement — красивее там, где может, читабельно везде.

Фоллбек для не-Chromium одной строкой

Если важно, чтобы тултип показывал правильное число и без scroll-driven animations, достаточно одной строки JS — она ставит ту же кастомную переменную напрямую из атрибута value:

document.querySelectorAll('progress.bar').forEach(p => {
  p.style.setProperty('--pct', Math.round(p.value / p.max * 100));
});

Подходит как для статичных прогрессов на странице, так и для динамических — пересчёт делается при каждом обновлении value. В Chromium эта строка просто переопределит ту же переменную, которую считает CSS, поведение не сломает.