Введение

Рассмотрим способ реализации “параметризованной” css анимации React компонента с помощью styled components. Параметризованная потому что css анимация описывается через параметры, которые динамически рассчитываются на основе пропсов и состояний компонента при его рендеринге.

Идея возникла при необходимости создания анимации таймера:

Требуемая анимация
Требуемая анимация

Анимация цифры не представляет трудности, а вот бегущая закрашенная часть границы достаточно интересна. Длина закрашенной части границы уменьшается пропорционально тому, как таймер стремится к 0.

Если принять изначально, что таймер считает всегда 10 секунд, то написать css анимацию вполне просто. Но хочется сделать красивый универсальный компонент, в который просто можно передать количество секунд и получить красивый результат. То есть анимация должна рассчитываться в зависимости от стартового значения таймера.

Для решения поставленной задачи пришла идея воспользоваться одной из основных возможностей styled components — передачей пропсов для параметризации стилей  (тут написано на сколько это круто).


К делу

Создаем компонент. На вход компонент принимает только один параметр time — время сколько необходимо считать. Дальше создаем состояние компонента currentNum — текущее значение счетчика.

  • В useEffect прописываем обновление счетчика, в случае изменения time.

  • <GreyFonPopup> — просто внешний div, который закрывает всё приложение серой полупрозрачной пленкой (потому что компонент разрабатывался как pop-up).

  • <TimerContainer> — блок таймера на котором и происходит анимация рамки.

  • <NumberContainer> и вложенные в него блоки <span> служат для анимации переворачивания цифры счетчика, подробно разбираться в них не будем. Единственное, важно отметить, что на span висит анимация длительностью в секунду, поэтому событие onAnimationEnd() используется как таймер для отсчета и в нём уменьшается счетчик компонента.

Для создания на основе пропсов анимации прокидываем их в TimerContainer.

import { useEffect, useState } from 'react';
import { GreyFonPopup, NumberContainer, TimerContainer } from './style';

interface IPropsTimer {
  time: number;
}

export const Timer: React.FC<IPropsTimer> = ({ time }) => {

  const [currentNum, setCurrentNum] = useState(time);

  useEffect(() => {
    setCurrentNum(time);
  }, [time])

  return (
    <GreyFonPopup>
      <TimerContainer
        time={time}
        currentNum={currentNum}
      >
        <NumberContainer>
          <span
            key={currentNum}
            onAnimationEnd={
              () => {
                currentNum > 1 && setCurrentNum(currentNum - 1);
              }
            }
          >{currentNum}</span>
          <span
            key={-currentNum}
          >{currentNum - 1}</span>
        </NumberContainer>
      </TimerContainer>
    </GreyFonPopup>
  )
}

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

Принцип создания анимированной рамки. Источник тут
Принцип создания анимированной рамки. Источник тут

Следовательно, в нашем случае на псевдоэлементе before, работают две анимации.
Первая — простое бесконечно вращение, вторая — анимация отвечающая за изменение длины закрашенной части границы (угла заливки градиента), которую необходимо рассчитывать исходя из начального и текущего значения таймера.

Для расчета и инициализации этой анимации вызываем функцию animateBorder() и передаем в неё рассчитанные на основе пропсов параметры.

360 * props.currentNum / props.time — текущая величина светящейся части контура в градусах;
360 / props.time  — шаг изменения длины светящейся части контура в градусах.

export const TimerContainer = styled.div<{ time: number; currentNum: number }>`
  position: relative;
  z-index: 0;
  width: 200px;
  height: 150px;
  border-radius: 12px;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;  

  &::before{
    content: '';
    position: absolute;
    z-index: -2;
    left: -50%;
    top: -50%;
    width: 200%;
    height: 200%;
    animation: ${props => animateBorder(360 * props.currentNum / props.time, 360 / props.time)}, 
              ${animateBorderRotate} 4s linear infinite;
  }

  &::after{
    content: '';
    position: absolute;
    z-index: -1;
    left: 2px;
    top: 2px;
    width: calc(100% - 4px);
    height: calc(100% - 4px);
    background: #14181f;
    border-radius: 12px;
  }
`;

Функция для создания анимации animateBorder(), как уже было сказано, принимает два параметра:

deg — значение в градусах, соответствующее текущей длине окрашенной линии границы, step — шаг в градусах, на который надо изменить длину этой линии.

Сама функция создает ключевые кадры анимации и возвращает css анимацию на их основе.

const animateBorder = (deg: number, step: number) => {
  const anim = keyframes`
    0%{
      background: conic-gradient(
        #37f 0deg ${`${deg}deg`},
        #14181f ${`${deg}deg`} 0deg
        );
    }
    100%{
      background: conic-gradient(
        #37f 0deg ${`${deg - step}deg`},
        #14181f ${`${deg - step}deg`} 0deg
        );
    }
  `
  return css`${anim} 1s linear forwards 1`
};

Но так как градиент фона не любит анимироваться, визуально анимация будет совсем не та. Линия уменьшается не плавно, а как-будто от неё периодически откусывают кусок:

Выход есть, пусть и не очень красивый. Вручную прописать промежуточные состояния. Однако если прописать порядка 10 промежуточных точек, то при счете от большого значения (то есть при малом шаге изменения длины) всё будет работать, а при счете от малых значений (большой шаг изменения длины), визуально будет выглядеть отрывисто. Если же описать много промежуточных точек, то будет выглядеть всегда плавно, но будет не рационально для анимации счета от большого числа, и код будет сильно раздут.

Поэтому воспользуемся ещё одним преимуществом JS — напишем цикл. В цикле будем составлять ключевые кадры анимации, и сделаем количество точек зависящим от шага анимации.

const animateBorder = (deg: number, step: number) => {
  let myKeyframes = ``;
  const delta = Math.round(400 / (360 / step));
  for (let i = 0; i < delta + 1; i++) {
    myKeyframes += `
              ${100 * i / delta}%{
                background: conic-gradient(
                  #37f 0deg ${`${deg - step * i / delta}deg`}, 
                  #14181f ${`${deg - step * i / delta}deg`} 0deg
                  );
              }`
  }

  const anim2 = keyframes`${myKeyframes}`;

  return css`${anim2} 1s linear forwards 1`
};

Ну вот и красота. Мы написали css анимацию компонента, которая зависит от переданных в него параметров, и сделали это без описания кучи разных классов и анимации для них.

Код проекта с итоговой версией таймер на Github  

А тут можно посмотреть на работу таймера   

P.S. надеюсь данный материал поможет кому-то в создании прекрасной анимации. Если же я допустил ошибки или вы имеете своё мнение по данному подходу, с удовольствием жду вас в комментариях.

Комментарии (4)


  1. sfi0zy
    28.07.2023 19:32
    +6

    А почему вы решили отказаться от стандартной практики рисования линий через свойства stroke-dasharray и stroke-dashoffset?

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


    1. GragertVD Автор
      28.07.2023 19:32

      Спасибо за комментарий.

      Первоначально про stroke-dasharray и stroke-dashoffset. Не использовал только потому что не знал про такой способ (ещё не доводилось работать с svg анимацией), а при запросе во всемирную сеть "как анимировать границу блока" множество сайтов предложило вариант решения через псевдоэлементы, в частности тот, на который я давал ссылку. Но сейчас, изучив ваше предложение, действительно такую границу, скорее всего, лучше делать через svg анимацию.

      А по поводу статьи в целом, она всё же не про конкретную реализацию "бегущей" границы, а про css анимацию с помощью styled components. Просто рассказывать было проще на примере. То есть тут про избегание настройки анимации через обращение к элементам по средствам JS. То есть про то, что можно записать анимацию keyframes через переменные, используя CSS-in-JS, и она будет создаваться при новом рендеринге на основе входных данных.

      Даже если использовать stroke-dasharray и stroke-dashoffset можно применить предложенный подход, и составить ключевые кадры на основе переменных для них, и избежать использования JS.

      Суть не в анимации границы, а в избегании обращения к элементам через JS. То есть, если мы вдруг захотим, чтобы в компонент можно было бы передать пропс с цветом цифры, причем так, что можно задать цвет счетчика в момент счета и по завершению, вариантов реализации всего несколько (если не ошибаюсь):

      1. Через JS обращаться к элементу и менять его цвет через style.

      2. Через JS вешать на элементы класс с соответствующим цветом.

      И я предлагаю третий вариант, без необходимости обращении к элементу через JS


      1. sfi0zy
        28.07.2023 19:32

        Справедливости ради стоит сказать, что ваш код по сути занимается вариантом №2. Раз в секунду навешивает новые классы с новыми стилями. Тут, наверное, нужно говорить не о принципиальной позиции "дергать элемент из JS, или не дергать", потому что дергать придется в любом случае, а о частоте дергания. Мы можем все промежуточные значения в анимации рассчитывать скриптом и дергать элементы 60 раз в секунду, а можем рассчитывать какие-то точки отсчета и создавать keyframes на их основе, дергая элементы по мере необходимости. Тут можно передавать нужные значения в мир CSS через custom properties и их использовать в keyframes, или можно взять Web Animations API, если уж хочется прямо генерировать keyframes в скриптах. Получится почти то же самое, что и вы придумали с генерированием фреймов, только нативное, без завязки на экосистему реакта.


    1. GragertVD Автор
      28.07.2023 19:32

      Зачем я предложил этот "третий" вариант, и вообще решил, что его можно выделить?

      Скорее всего просто потому что сам хотел избежать обращение к элементу через JS, потому что читал, что css анимация лучше, чем менять стили через JS, и потому что в React не хотел писать весь необходимый JS с обращением к элементам.

      Но основная причина в том, что такой метод точно есть (у меня же заработало), а в интернете его описание не нашел. Поэтому решил что это прекрасная возможность попробовать себя в написании статей).

      А вопрос, что лучше в плане производительности, мне интересен, но до его изучения ещё не добрался.