Как анимировать контур при помощи CSS

alexei12/02/2024 - 09:47
Как анимировать контур при помощи CSS

Загрузчики и индикаторы прогресса являются одними из наиболее широко используемых примеров в руководствах и учебниках по CSS. Существует огромное множество способов их реализации. Возможно, некоторые подходы будут "лучше" других, но это также зависит от того, чего вы хотите добиться. В этой статье будет показан подход, использующий анимированные пользовательские свойства, конический градиент, CSS свойство offset и эмодзи, чтобы создать анимацию скутера, мчащегося по круглой дорожке.

Когда возникла задача создать для проекта индикатор прогресса, первое, что пришло в голову, это поискать вдохновения в Интернете. Нам нужен был индикатор круглой формы, и недостатка в примерах не было. Большинство примеров предлагало использовать некую комбинацию CSS свойства border-radius для получения круглой формы и правила @keyframes для определения анимации ее вращения от 0deg до 360deg.

Тем не менее нам требовалось немного больше. В частности, нужна была форма пончика, которую заполняет индикатор прогресса от 0% до 100%. К счастью, мы нашли отличные примеры пончиков и несколько разных подходов их создания, которые можно было использовать в качестве базы. Кроме этого, в Интернете есть сотни примеров, в которых используется комбинация CSS-градиентов и масок.

Однако нам все еще нужно было нечто большее. Это должен был быть индикатор прогресса в форме пончика, который будет не только заполняется в ходе прогресса, но и иметь визуальный элемент, который будет перемещаться вместе с прогрессом. Другими словами, это должен был быть некий объект, перемещающийся по пончику и оставляющий за собой след, показывающий прогресс.

See the Pen Circular animation with offset Pt. 1 by Alexei Goloviznin on CodePen.

Видите это? У мотороллера есть круглая дорожка, которая заполняется градиентом по мере движения по круглой фигуре. Если у вас Firefox старых версий, то скорее всего демонстрацию вы не увидите, так как она основана на пользовательском свойстве @property, которое в старых версиях Firefox не поддерживается.

В итоге мы объединили несколько найденных техник и добавили пару дополнительных соображений. В этой демонстрации для создания нужного эффекта используются анимированные пользовательские свойства, конический градиент, offset и эмодзи. Тем не менее вы можете найти другую комбинацию или набор техник, которые лучше выполняют эту работу или лучше соответствуют вашим требованиям. Это скорее упражнение-размышление.

Создание фигуры "пончика"

Круги в CSS довольно просты. Мы могли бы нарисовать их в SVG и полностью забыть о CSS. Это вполне допустимый подход, но в данном случае нам удобнее работать непосредственно с CSS. Таким образом, начинаем с создания одного HTML элемента:


<div class="progress-circle"></div>

Теперь зададим размеры фигуры. Это можно сделать при помощи свойства width. Свойство aspect-ratio задаст идеальную форму в пропорции "один к одному":


.progress-circle {
  width: 200px; 
  aspect-ratio: 1;
}

Затем скруглим нашу фигура при помощи свойства border-radius:


.progress-circle {
  width: 200px; 
  aspect-ratio: 1;
  border-radius: 50%;
}

Вот это и будет нашей формой! Мы, конечно, пока ничего не увидим, потому что не залили ее цветом. Давайте сделаем это сейчас с помощью conic-gradient. По умолчанию градиент движется по кругу по часовой стрелке, начиная с 0% и завершая полный круг на 360deg.


.progress-circle {
  width: 200px; 
  aspect-ratio: 1;
  border-radius: 50%;
  background: conic-gradient(red 10%, #eee 0); 
}

Пока все неплохо:

See the Pen Conic Gradient Circle by Smashing Magazine on CodePen.

У нас получилось нечто похожее на круговую диаграмму. Мы создали круглую форму и залили ее коническим градиентом, который начинается с красного цвета, заполняет им 10% формы и заканчивается четким переходом к светло-серому (#eee) цвету, заливая им остальную часть формы.

Получился симпатичный пирог, но нужен пончик, а в центре пончиков вырезано отверстие. В истинном духе CSS к этому можно подойти по-разному. Существует множество вариантов, как CSS-маски могут создавать вырезы. Это тоже чистый подход, потому что, чтобы вырезать круг в центре, мы можем переназначить тот же конический градиент, изменив только значения цветов, поместив под маску ту часть, которую мы хотим скрыть.

Но мы пошли другим путем, отчасти для удобства, а отчасти ради демонстрации того, как CSS способен решать сложные задачи несколькими способами. Таким образом, вы можете даже найти свой путь, отличный от показанного здесь. Наш подход заключается в использовании псевдоэлемента ::before у селектора .progress-circle. Мы накладываем его поверх конического градиента с абсолютным позиционированием, заливаем сплошным цветом и подгоняем размер таким образом, чтобы он закрывал часть основной формы. По сути, это меньший круг сплошного цвета поверх большего круга, заполненного градиентом.


.progress-circle {
    /* предыдущие стили */
    position: relative;
}

.progress-circle::before {
  content: '';
  position: absolute;
  inset: 20px; 
  border-radius: inherit;
  background: white;
}

Обратите внимание, что мы делаем для позиционирования меньшего круга. Поскольку мы имеем дело с псевдоэлементом ::before, нам нужно CSS свойство content даже с пустым значением. Далее мы используем абсолютное позиционирование, устанавливая меньший круг в центр при помощи свойства inset с одинаковым значением для всех сторон. Перед тем, как задать цвет заливки внутреннего меньшего круга, мы унаследуем скругление от внешнего большого круга задав значение inherit для свойства border-radius. Не забываем установить относительное позиционирование у большого круга, чтобы (а) задать контекст стекирования и (б) удержать меньший круг внутри большого круга.

See the Pen conic-gradient() by Alexei Goloviznin on CodePen.

Вот и все с пончиком! Мы сделали его исключительно на CSS, полагаясь на комбинацию свойств border-radius, conic-gradient и правильно позиционированного псевдоэлемента ::before.

Анимирование прогресса

Вы когда-нибудь имели дело с пользовательскими свойствами CSS? Мы имеем в виду не просто определение вроде --some-variable со значением, а использование правила @property для регистрации свойства с пользовательским синтаксисом. Это настоящее чудо, позволяющее выполнять интерполяцию между разными типами значений (например, между значениями цвета и угла в градиентах), которую обычными средствами сделать невозможно.

Когда мы регистрируем пользовательское свойство CSS при помощи правила @property, мы должны указать тип его значения, например, <length>, <number>, <color> или любое другое из 11 допустимых типов, которые поддерживаются на момент написания этой статьи. Таким образом, браузер понимает, с каким значением он работает, и, когда приходит время, он может обновить значение переменной для анимации.

Мы собираемся зарегистрировать пользовательское свойство с именем --p, которое является сокращением от его синтаксиса, <percentage>, с начальным значением 10%, которое будет отправной точкой для индикатора прогресса.


@property --p {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 10%;
}

Теперь мы можем использовать переменную --p там, где она нам нужна, например, где жестко заканчивается цвет red и начинается цвет #eee в коническом градиенте для нашей отправной точки на большом круге.


.progress-circle {
    /* предыдущие стили */ 
    background: conic-gradient(red var(--p), #eee 0); 
}

Нам нужно, чтобы граница между цветами перемещалась от начального значения пользовательского свойства, 10%, к большему проценту. Таким образом, нам нужно настроить CSS свойство transition, которое и будет обновлять значение --p.


.progress-circle {
  /* предыдущие стили */ 
  background: conic-gradient(red var(--p), #eee 0); 
  transition: --p 2s linear;
}

Мы собираемся обновлять значение при наведении курсора мыши, переходя от 10% к 80%:


.progress-circle:hover{
  --p: 80%;
}

И еще одно небольшое изменение: хорошо бы при наведении курсора мыши изменить свойство cursor, чтобы было понятнее, с каким взаимодействием имеет дело пользователь. В данном случае у нас в наличие нечто, указывающее на прогресс, поэтому мы это настроим вот так:


.progress-circle {
  /* предыдущие стили */
  cursor: progress;
}

See the Pen conic-gradient() animation by Alexei Goloviznin on CodePen.

Наш круг завершен! Теперь мы можем навести курсор на элемент, и цвет конического градиента, часть которого скрыта за малым кругом, создающим форму пончика, отправится с 10% к 80%. Мы зарегистрировали пользовательское правило @property с начальным значением, применили его к градиенту и обновили значение при наведении курсора мыши.

Движение по кругу

Заключительной частью этого упражнения является кодирование индикатора прогресса. Мы используем градиент для обозначения прогресса, но нам хотелось бы добавить дополнительную визуальную помощь в виде объекта, который будет перемещаться по большому кругу вслед за движением цвета градиента.

В голову пришла мысль в качестве такого объекта сделать маленький скутер, который во время движения будет оставляет за собой след из цвета градиента. У нас уже есть круг и градиент, поэтому все, что нам нужно, - это добавить скутер и найти способ заставить его использовать больший круг в качестве дорожки для передвижения.

See the Pen CSS offset animation by Alexei Goloviznin on CodePen.

Давайте продолжим и добавим скутер в HTML в виде эмодзи:


<div class="progress-circle">
  <div class="progress-indicator">🛵</div>
</div> 

Если бы мы решили создать начальную форму пончика с помощью SVG, то могли бы использовать тот же контур, который мы использовали для большого круга в качестве дорожки. Тем не менее, мы все еще можем получить те же возможности для создания контуров в CSS, используя свойство offset-path. Это настолько похоже на написание SVG, но только в CSS, что мы можем фактически использовать точно такие же координаты, что и для SVG-окружности в path():


.chart-indicator {
  /* предыдущие стили */
  offset: path("M 100, 0 a 100 100 0 1 1 -.1 0 z");
}

Координаты контура в формате SVG трудно прочитать, но это то, что мы сделаем в этом конкретном контуре:

  1. M 100, 0: Эта комбинация команд перемещает положение начальной точки в системе координат X-Y, где 100 - по оси X и равна большему радиусу окружности, или половине ее ширины в 200px. Начальная точка установлена в 0 на оси Y, располагая ее в верхней части фигуры. Итак, мы начинаем с верхнего центра большого круга.
  2. a 100 100: Задает дугу с горизонтальным и вертикальным радиусами 100, давая нам новую окружность. Хотя технически мы и не увидим круг, он там есть, обеспечивая скутеру невидимую дорожку, повторяющую форму большого круга.

И еще кое-что! Благодаря координатам в offset-path у нас есть отправная точка для скутера. CSS свойство offset-distance позволяет нам определить конечную точку, к которой мы планируем смещать скутер и которая в точности равна пользовательскому свойству --p.


.chart-indicator {
  /* предыдущие стили */
  offset-path: path("M 100, 0 a 100 100 0 1 1 -.1 0 z");
  offset-distance: var(--p);
}

Мы уже обновляем наше пользовательское свойство --p при наведении курсора мыши, чтобы помочь переместить положение цветовой границы конического градиента с начального значения 10% на 80%. Теперь мы должны сделать то же самое для скутера, чтобы они двигались вместе.


.progress-circle:hover > .progress-indicator {
   --p: 80%;
}

Мы используем дочерний комбинатор (>), поскольку индикатор является прямым дочерним элементом круга. Если ваш дизайн включает дополнительные элементы или требует, чтобы скутер был более далеким потомком, то вместо этого комбинатора вы можете использовать общий селектор потомков.

Конечный результат

Вот все, рассмотренное нами, в едином фрагменте CSS кода. Мы всего лишь немного подчистили некоторые элементы, такие как настройка переменных для повторяющихся значений, таких как размер круга --size.


/* Пользовательское свойство */
@property --p {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 10%;
}

/* Большой круг */
.progress-circle {
  --size: 200px; 
  --p: 10%; /* обратная совместимость для @property */

  background: conic-gradient(red calc(-60% + var(--p)), rgb(224, 187, 77) var(--p), #eee 0);
  border-radius: 50%;
  position: relative;
  margin: auto;
  cursor: progress;
}

/* Маленький круг */
.progress-circle::before {
  content:'Идем от 10% до 80%';
  position: absolute;
  inset: 20px; 
  text-align: center;
  padding: 50px;
  font: italic 9pt 'Enriqueta';
  border-radius: inherit;
  background: white;
}

/* Дорожка для скутера */
.progress-indicator {
    --size: min-content; 
    offset: path("M 100,0 a 100 100 0 1 1 -.1 0 z");
    offset-distance: var(--p);
    font: 43pt serif;
    transform: rotateY(180deg) translateX(-6px);
}

/* Обновляем начальное значение при наведении */
.progress-circle:hover,
.progress-circle:hover > .progress-indicator { 
  --p: 80%;
}

/* Контролирует ширину большого круга и дорожки скутера */
.progress-circle,
.progress-indicator {
    width: var(--size);
    transition: --p 2s linear;
}

See the Pen Circular animation with offset Pt. 1 by Alexei Goloviznin on CodePen.

Скутер и сплошной градиент - это только один из вариантов этой идеи. Как насчет разных объектов с разными контурами?

See the Pen Circular animation with offset Pt. 2 by Alexei Goloviznin on CodePen.

На протяжении всей статьи мы называли данный компонент одновременно "индикатором прогресса" и "загрузчиком". Тем не менее существует определенная разница между отображением хода выполнения и состоянием загрузки, но при этом также возможно, что состояние загрузки отображает и прогресс загрузки. Вот почему в примере мы используем общий элемент <div> в качестве элемента <figure>, но данную технику вы могли бы с таким же успехом использовать и с более семантическими элементами HTML, такими как <progress> или <meter>. Для обеспечения доступности вы могли бы рассмотреть возможность включения описательного текста, который может быть представлен в виде предложений, удобных для вспомогательных технологий и описывающих данные. Все зависит от вашего конкретного варианта использования