くるくる回るように数字が変化するカウンターを作りました。
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> ); } `;