Нативный <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, она доступна во всех живых браузерах больше года:
Scroll-driven animations — ключевой кирпич всего приёма — пока живут только в Chromium и в свежем Safari, Firefox с поддержкой пока запаздывает:
Anchor positioning подъехал последним — и в Firefox, и в Safari он появился совсем недавно:
Итог: в текущем 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, поведение не сломает.
Комментарии (0)