🥇

数字をカウントアップするコンポーネントを作る時に考慮したあれこれ

2022/12/04に公開約8,200字

仕様

数値をカウントアップしていくコンポーネントをシンプルに作ってみる。


カウントアップの完成イメージ

  • どこまでカウントアップするか数値を指定できる
  • 0 から指定された数値まで 1 ずつカウントアップした表示をしていく

利用した技術スタック

今回、実装する際に使った技術スタックは以下の通り。

  • @emotion/css: v11.10.5
  • @emotion/react: v11.10.5
  • @emotion/styled: v11.10.5
  • react: v18.2.0
  • react-dom: v18.2.0

上記のライブラリが必要なわけではなく、自身が実装しやすい環境で行っただけである。

また、カウントアップ用のライブラリは使用しないものとする。

CSS だけでの実装

カウントアップ表示をするためには動的に数値を変化させる必要がある。@propertyというアットルールを利用すると JavaScript を書かずに CSS だけで実装することが出来る。

property ルールは、 JS を実行することなく、スタイルシートの中で直接カスタムプロパティの登録を表します。有効な @property ルールは、あたかも CSS.registerProperty が同等のパラメータで呼び出されたかのように、登録されたカスタムプロパティを生成します。

https://developer.mozilla.org/ja/docs/Web/CSS/@property

ただし、執筆当時 @property ルールが利用できるのは Chrome (Blink)系のブラウザのみに限られている。

https://caniuse.com/mdn-css_at-rules_property

@property ルールを実装する

まず、以下のように @property ルールを定義する。

@property --count-number {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}
JavaScriptでの定義例

JavaScript でも定義が可能。定義例は以下の通り。

CSS.registerProperty({
  name: '--color-number',
  syntax: '<integer>',
  inherits: false,
  initialValue: 0,
});
プロパティ 説明
syntax プロパティに許容される構文
inherits @propertyで指定したカスタムプロパティの登録を既定で継承するかどうか
initial-value プロパティの初期値

アニメーションを実装する

@keyframesの定義とanimationを組み合わせる。

指定された数値までカウントアップさせるため、アニメーションもその数値までカウントアップさせられるように数値を受け付けられるような作りにする。

type Props = {
  maxCount: number;
};

const countAnimation = ({ maxCount }: Props) => keyframes`
  from {
    --count-number: 0;
  }
  to {
    --count-number: ${maxCount};
  }
`;

次に定義したkeyframesを対象の要素に組み込む(animation)。

疑似要素で content プロパティを利用して数値を表示するが、 content プロパティは文字列しか表示ができないため、counter()を使って数値を文字列に変換している。

const CountUpCss = styled.span<Props>`
  --count-number: ${(props) => props.maxCount};

  animation: ${countAnimation} 5000ms alternate linear;
  counter-reset: counter var(--count-number);

  &::after {
    content: counter(counter);
  }
`;

https://css-tricks.com/animating-number-counters/


countAnimation 部分の補足

countAnimation を引数なしで指定している。

animation: ${countAnimation} ${(props) => props.duration ?? 5}s alternate ease-in-out;

countAnimationには props の値が渡っているため、countが参照できている。

引数を渡そうとすると以下のようになってしまい、可読性が落ちるので今回は引数なしで定義している。

animation: ${(props) => countAnimation({ count: props.maxCount })} 5000ms alternate linear;

@propertyが使えるかどうかのチェック方法

先述の通り、@propertyルールは現在すべてのブラウザで使えるわけではない。なので使えるかどうかの判定は入れておきたい。

判定には、CSS と JavaScript それぞれで判定することができる。

CSS での判定する

CSS が対象のプロパティを利用できるか判定するには@supportsを利用する。これは実装済みのプロパティのサポート状況に応じた定義分けをするために利用できるものである。

@propertyルールは、プロパティではないため@supportsで判定できない。

そのため、ブラウザが@propertyルールをサポートしているブラウザーとサポートしていないブラウザーとで条件が合致している他の CSS プロパティを指定して、@propertyルールが利用できるか判定を行う。

/* Check for Houdini support & register property */
@supports (background: paint(something)) {
  @property --gradPoint {
    syntax: "<percentage>";
    inherits: false;
    initial-value: 40%;
  }
}

paint()が利用できるブラウザは、@propertyルールも利用できるため上記のような判定になる。これはweb.dev でも紹介されている方法ではあるが、実際には@propertyルールが利用できるかをチェックしているわけではないため、直感的ではなく個人的にはあまり使いたくはない方法ではあった。

ちなみに Houdini のサポート状況は以下で確認することができる。

https://ishoudinireadyyet.com/

JavaScript で判定する

JavaScript でも判定方法は、registerPropertyが利用できるかを判定すれば良い(利用できる場合はwindow.CSSregisterProperty存在している)。

const enableRegisterProperty =
  // @ts-ignore
  typeof window.CSS.registerProperty !== "undefined";

実験的な機能であるため、TypeScript を利用していると型が存在しないと警告が出る。@ts-ignoreで回避した。


CSS での判定方法に比べ、こちらは@propertyルールが利用できるかどうかに直結した判定になっているため信憑性は高いだろう。

注意点

カウントアップの仕様によっては、要件を満たさない場合がある。

  • Chrome (Blink)系のブラウザしか対応していない(Firefox、Safariが未対応)
    https://caniuse.com/mdn-css_at-rules_property
  • 文字列は(「1,000」のようにカンマ区切りが入っているもの)はアニメーションできない
  • 疑似要素で数字を表示しているため、素直にテキストのコピーができない

JavaScript メインでの実装

CSS で実装できればパフォーマンス的にも良いのだが、特定のブラウザでしか対応できておらず、JacaScript での実装をしなければならない場合がほとんどだろう。

JavaScript での実装については以下の通り。

一般的なカウントアップの実装と特に変わった実装はしておらず、指定の数値までインターバルで更新を実行していくものとなっている。数値の表示方法については、textContentを利用せずCSS の疑似要素の表示(attr(data-number))で行うようにしており、更新方法もdata 属性を更新するだけにしている。

const CountUpJsInner = styled.span`
  &::after {
    content: attr(data-number);
  }
`;

const CountUpJs = ({ maxCount }: Props) => {
  const ref = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    let currentCount = 0;
    let requestId = -1;

    const timer = () => {
      const current = ref.current;
      if (!current) return;

      if (currentCount < maxCount) {
        current.dataset.number = String(currentCount + 1);
        currentCount += 1;
        requestId = window.requestAnimationFrame(timer);
      }
    };

    requestId = window.requestAnimationFrame(timer);

    return () => window.cancelAnimationFrame(requestId);
  }, [maxCount]);

  return (
    <>
      <CountUpJsInner ref={ref} data-number="0" aria-hidden="true" />
      <span className="sr-only">{maxCount}</span>
    </>
  );
};
requestAnimationFrameではなくsetIntervalでの実装
const CountUpJs = ({ count }: Props) => {
  const ref = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    let currentCount = 0;

    const timer = () => {
      const current = ref.current;
      if (!current) return;

      if (currentCount < count) {
        current.dataset.number = String(currentCount + 1);
        currentCount += 1;
      }
    };

    const timerInterval = setInterval(timer, Math.trunc(5000 / count));

    return () => clearInterval(timerInterval);
  }, [count]);

  return <span ref={ref} data-number="0" aria-hidden="true" />;
};

アクセシビリティの考慮

aria-hidden

<CountUp data-number="0" aria-hidden="true"/>
<span className="sr-only">{maxCount}</span>

カウントアップ完了時点でなければ意図した数値で読み上げすることはできないないため、カウントアップをするコンポーネント自体はaria-hidden="true"を付けてスクリーンリーダーで読み上げしないようにしておく。読み上げ用の要素は別途スクリーンリーダーに読ませるための要素として置いておく。

https://b.0218.jp/202110191552.html

aria-livearia-busy

<CountUp data-number="0" aria-live="polite" aria-busy="true" />

この指定によって、要素の更新を現在の操作後に読み上げるようになる。だが、カウントアップが完了したときに読み上げられても困るし、今回は読み上げ用の要素を用意していることもあり使用しなかった。

注意点

  • 他の処理の負荷の影響で期待する時間でカウントアップが終了しない可能性がある
  • 疑似要素で数字を表示しているため、素直にテキストのコピーができない

その他

数字の変更時のガタツキをなくす

数字が連続で変わっていくため、数字やフォントによってはガタツキが生じてしまう。

この事象を解消させるためには、対象の要素に CSS でfont-variant-numericプロパティを指定すると良い。

下記のような指定をすると、数字は数字を等幅で表示できるようになる。

font-variant-numeric: tabular-nums;

また、font-variant-numericプロパティは、プロポーショナルフォントと等幅フォントを混在させることもできる。

https://caniuse.com/font-variant-numeric

表示サンプル:

https://twitter.com/javan/status/1486059026064584711

完成

CSSの方がパフォーマンスが良いため、@propertyルールが利用できるブラウザにはCSSを表示し、そうではないブラウザにはJavaScriptメインのコンポーネントを表示するようにした。

デモ

最後に

今回は、数字をカウントアップするシンプルなコンポーネントを作成した際のあれこれについて記載した。Reactコンポーネントで作成しているが、素の JavaScript でも考慮する点はあまり変わらないと思う。

Discussion

ログインするとコメントできます