Как мне сделать анимированный счетчик с помощью GSAP на реакте?

https://youtu.be/dc02CwWTwuI

Как мне реализовать анимированный счетчик на gsap, или на anime.js, или на на React. Как показано по ссылке на пример. Состояние пагинации и массивы с датами лежат в slice redux-toolkit.

const initialState: PostsState = {
    currentPage: 1,
    dates: {
        1: [2015, 2019],
        2: [1987, 1990],
        3: [2010, 2014],
        4: [1926, 1930],
        5: [2000, 2004],
        6: [2020, 2024]
    },
};

const postsSlice = createSlice({
    name: 'posts',
    initialState,
    reducers: {
        setCurrentPage(state, action: PayloadAction<number>) {
            state.currentPage = action.payload;
    },
}});

Ответы (2 шт):

Автор решения: Михаил Камахин

Решение на GSAP

Этот кусок отвечает за анимацию, gsap плавно изменяет свойства объекта currentDate, а потом выводится на экран

tl.to(currentDate, { dateStart: date.dateStart, dateEnd: date.dateEnd, onUpdate: () => {
    renderCurrentDate(currentDate, currentDateNodes);
}});

Всё остальное - рендеринг html элементов на странице

const dates = [
  { dateStart: 2015, dateEnd: 2019 },
  { dateStart: 1987, dateEnd: 1990 },
  { dateStart: 2010, dateEnd: 2014 },
  { dateStart: 1926, dateEnd: 1930 },
  { dateStart: 2000, dateEnd: 2004 },
  { dateStart: 2020, dateEnd: 2024 },
];
const wrapperNode = renderWrapperNode();
const currentDate = {
  dateStart: dates[0].dateStart,
  dateEnd: dates[0].dateEnd
};

const periodBtnNodes = renderPeriodBtns(dates, wrapperNode);
const currentDateNodes = renderInitCurrentDate(currentDate, periodBtnNodes, wrapperNode);
const tl = gsap.timeline({ defaults: { duration: 2 } });

function renderWrapperNode() {
  const wrapperNode = document.createElement('div');

  wrapperNode.classList.add('wrapper');
  document.body.append(wrapperNode);

  return wrapperNode;
}

function renderPeriodBtns(dates, wrapperNode) {
  const btnNodes = dates.map(date => {
    const btnNode = document.createElement('button');

    btnNode.classList.add('btn');
    btnNode.type = 'button';
    btnNode.textContent = `${date.dateStart} - ${date.dateEnd}`;
    btnNode.dataset.dateStart = date.dateStart;
    btnNode.dataset.dateEnd = date.dateEnd;

    btnNode.addEventListener('click', (event) => clickPeriodBtnListener(event, date, currentDateNodes, tl, btnNode));

    return btnNode;
  });

  btnNodes[0].classList.add('btn_active');

  const periodBtnsWrapperNode = document.createElement('div');

  periodBtnsWrapperNode.classList.add('period-btns-wrapper');

  periodBtnsWrapperNode.append(...btnNodes);
  wrapperNode.prepend(periodBtnsWrapperNode);

  return btnNodes;
}

function renderInitCurrentDate(currentDate, periodBtnNodes, wrapperNode) {
  const curentDateNode = document.createElement('div');

  curentDateNode.classList.add('current-date');
  curentDateNode.innerHTML = `
    <div class="current-date__start">${currentDate.dateStart}</div>
    <div class="current-date__end">${currentDate.dateEnd}</div>
  `;
  wrapperNode.append(curentDateNode);
  
  const currentDateStartNode = curentDateNode.querySelector('.current-date__start');
  const currentDateEndNode = curentDateNode.querySelector('.current-date__end');
  
  return {
    currentDateStartNode,
    currentDateEndNode,
    curentDateNode
  };
}

function renderCurrentDate(newDate, {
  currentDateStartNode,
  currentDateEndNode,
}) {
  currentDateStartNode.textContent = Math.round(newDate.dateStart);
  currentDateEndNode.textContent = Math.round(newDate.dateEnd);
}

function clickPeriodBtnListener(event, date, currentDateNodes, tl, btnNode) {
  if (tl.isActive() === true) return;

  toggleActiveBtnClass(btnNode);
  tl.to(currentDate, { dateStart: date.dateStart, dateEnd: date.dateEnd, onUpdate: () => {
    renderCurrentDate(currentDate, currentDateNodes);
  }});
}

function toggleActiveBtnClass(btnNode) {
  const btnNodes = document.querySelectorAll('.period-btns-wrapper .btn');

  for (const btnNodeItem of btnNodes) {
    btnNodeItem.classList.remove('btn_active');
  }

  btnNode.classList.add('btn_active');
}
.wrapper {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.period-btns-wrapper {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.btn {
  border: 2px solid gray;
  cursor: pointer;
}

.btn_active {
  border-color: black;
  cursor: default;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js" integrity="sha512-7eHRwcbYkK4d9g/6tD/mhkf++eoTHwpNM9woBxtPUBWm67zeAfFC+HrdoE2GanKeocly/VxeLvIqwvCdk7qScg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

→ Ссылка
Автор решения: Arbery

Если на react, то так, добавить useRef для элементов с начальной и конечной датами, useState для значений счетчиков и учета актуальных значений. В useEffect инициализировать gsap для анимации начальной и конечной точек с соответствующими зависимостями. Создать обработчик для изменения начальной и конечной дат и повесить на нужные элементы. Этот обработчик может использоваться как для блоков с датами, так для стрелочек перехода на следующую/предыдущую дату или отдельных блоков - достаточно передавать целевой индекс.

В рабочем примере скорректировано для запуска текущей платформе, на codeSandbox более каноничный вариант.

const dates = [
  { dateStart: 2015, dateEnd: 2019 },
  { dateStart: 1987, dateEnd: 1990 },
  { dateStart: 2010, dateEnd: 2014 },
  { dateStart: 1926, dateEnd: 1930 },
  { dateStart: 2000, dateEnd: 2004 },
  { dateStart: 2020, dateEnd: 2024 },
];

function App() {
  const startEl = React.useRef();
  const endEl = React.useRef();
  const [current, setCurrent] = React.useState(0);
  const [start, setStart] = React.useState(dates[current].dateStart || 0);
  const [end, setEnd] = React.useState(dates[current].dateEnd || 0);

  React.useEffect(() => {
    window.gsap.to(startEl.current, {
      innerText: start,
      duration: 1,
      snap: {
        innerText: 1,
      },
    });
    window.gsap.to(endEl.current, {
      innerText: end,
      duration: 1,
      snap: {
        innerText: 1,
      },
    });
  }, [start, end]);

  const handleClick = (idx) => {
    if (idx < 0) return;
    if (idx >= dates.length) return;
    setCurrent(idx);
    setStart(dates[idx].dateStart);
    setEnd(dates[idx].dateEnd);
  };

  return (
    <div className="App">
      <h3>
        <span ref={startEl}>{dates[0].dateStart}</span>
        <b> - </b>
        <span ref={endEl}>{dates[0].dateEnd}</span>
      </h3>
      <div className="nav">
        <button onClick={() => handleClick(current - 1)}>назад</button>
        <button onClick={() => handleClick(current + 1)}>вперед</button>
      </div>
      <br />
      <div className="list">
        {dates.map(({ dateStart, dateEnd }, idx) => (
          <div
            className="item"
            onClick={() => handleClick(idx)}
            key={(dateStart, dateEnd)}
          >
            <span>{dateStart}</span>
            <b> - </b>
            <span>{dateEnd}</span>
          </div>
        ))}
      </div>
    </div>
  );
}
  
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <App />
);
.App {
  font-family: sans-serif;
  text-align: center;
}
.list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
}
.item {
  padding: 10px;
  font-size: 14px;
  font-weight: 700;
  margin: 7px;
  border: 2px solid #bbb;
  cursor: pointer;
}
.item:hover {
  background-color: #bbb;
}
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<div id="root"></div>

В рабочем виде на codeSandbox:

react-gsap-riceDigits

→ Ссылка