みかづきブログ・カスタム

基本的にはちょちょいのほいです。

Reactでスロットのリールのようなカウンターを実装する 🎰

くるくる回るように数字が変化するカウンターを作りました。
styled-components、lodash、gsapに依存しています。
lodashはreverseしか使っていないので、reverseを自作して外すこともできたのですが、面倒なので使っちゃいました。
gsapに関しても、CSSアニメーションで実装することで外せないか頑張ってみたのですが、面倒なので使っちゃいました。

ReelNumbers/index.tsx

import styled from 'styled-components';
import { useEffect, useRef, useState } from 'react';
import { reverse } from 'lodash';
import { typeDirection, ReelNumberOl } from './ReelNumberOl';

export function ReelNumbers({
  currentNumber,
  fps,
  minDigits = 0
}: {
  currentNumber: number;
  fps: number;
  minDigits?: number;
}) {
  const [ currentNumberList, setCurrentNumberList ] = useState<string[]>([]);
  const [ direction, setDirection ] = useState<typeDirection>('');
  const lastNumberRef = useRef<number>(0);

  useEffect(() => {
    currentNumber = Math.max(currentNumber, 0);
    setDirection(getDirection(currentNumber, lastNumberRef.current));
    setCurrentNumberList(reverse([ ...String(currentNumber).padStart(minDigits, '0') ]));
    lastNumberRef.current = currentNumber;
  }, [currentNumber]);

  function getDirection(newNumber: number, oldNumber: number): typeDirection {
    if (newNumber === oldNumber) {
      return '';
    }

    return newNumber < oldNumber ? 'down' : 'up';
  }

  return (
    <Wrapper className="reel-numbers">
      <ol className="transparent-number">
        {currentNumberList.map((number, i) => {
          return <li key={ i }>{ number }</li>
        })}
      </ol>
      <div className="scroll-numbers">
        {currentNumberList.map((num, i) => {
          return (
            <ReelNumberOl
              key={ i }
              number={ Number(num) }
              fps={ fps }
              direction={ direction }
            />
          );
        })}
      </div>
    </Wrapper>
  );
}

const Wrapper = styled.div`
  position: relative;
  overflow: hidden;

  .transparent-number {
    display: flex;
    color: transparent;
  }

  .scroll-numbers {
    display: flex;
    justify-content: flex-end;
    flex-direction: row-reverse;
    position: absolute;
    top: 0; right: 0;
  }
`;

ReelNumberOl.tsx

import styled from 'styled-components';
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';

export type typeDirection = '' | 'up' | 'down';

export function ReelNumberOl({
  number,
  fps,
  direction = '',
}: {
  number: number;
  fps: number;
  direction?: typeDirection;
}) {
  const lastNumberRef = useRef<number>(-1);
  const isInitRef = useRef<boolean>(true);
  const scrollRef = useRef<HTMLOListElement>(null);

  useEffect(() => {
    if (scrollRef.current) {
      if (0 < lastNumberRef.current && lastNumberRef.current !== number) {
        isInitRef.current = false;
      }

      const progress = {
        current: 0
      };

      gsap.to(progress, {
        current: 1,
        duration: direction === '' ? 0 : 1 / fps,
        ease: 'linear',
        onUpdate: () => {
          if (scrollRef.current) {
            scrollRef.current.style.transform = `translateY(${ getNewY(direction, number, progress.current) }%)`;
          }
        }
      });
    }

    lastNumberRef.current = number;

    function getNewY(direction: typeDirection, newNumber: number, progress: number) {
      switch (direction) {
        case 'up':
          newNumber = !newNumber ? 10 : newNumber;

          return -100 / 11 * (newNumber - 1) - 100 / 11 * progress;
        case 'down':
          return -100 / 11 * (newNumber + 1) + 100 / 11 * progress;
        default:
          return -100 / 11 * newNumber;
      }
    }
  }, [number]);

  return (
    <Wrapper
      ref={ scrollRef }
      className="reel-number-ol"
    >
      {[...new Array(10)].map((_, i) => {
        return (
          <li key={ i }>{ i }</li>
        );
      })}
      <li>0</li>
    </Wrapper>
  );
}

const Wrapper = styled.ol`
  text-align: center;
`;

使い方

pages/index.tsx(抜粋)

import { useState } from 'react';
import { ReelNumbers } from '@/components/ReelNumbers';

export default function IndexPage() {
  const [ count, setCount ] = useState(0);
  const [ fps ] = useState(8);

  return (
    <div>
      <dl
        onClick={ () => setCount(count + 1) }
      >
        <dt>countup</dt>
        <dd>
          <ReelNumbers
            currentNumber={ count }
            minDigits={ 3 }
            fps={ fps }
          />
        </dd>
      </dl>
    </div>
  );
}
`;