数字をカウントアップするコンポーネントを作る時に考慮したあれこれ
仕様
数値をカウントアップしていくコンポーネントをシンプルに作ってみる。
カウントアップの完成イメージ
- どこまでカウントアップするか数値を指定できる
- 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
が同等のパラメータで呼び出されたかのように、登録されたカスタムプロパティを生成します。
ただし、執筆当時 @property
ルールが利用できるのは Chrome (Blink)系のブラウザのみに限られている。
@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);
}
`;
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 のサポート状況は以下で確認することができる。
JavaScript で判定する
JavaScript でも判定方法は、registerProperty
が利用できるかを判定すれば良い(利用できる場合はwindow.CSS
にregisterProperty
存在している)。
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"
を付けてスクリーンリーダーで読み上げしないようにしておく。読み上げ用の要素は別途スクリーンリーダーに読ませるための要素として置いておく。
aria-live
とaria-busy
<CountUp data-number="0" aria-live="polite" aria-busy="true" />
この指定によって、要素の更新を現在の操作後に読み上げるようになる。だが、カウントアップが完了したときに読み上げられても困るし、今回は読み上げ用の要素を用意していることもあり使用しなかった。
注意点
- 他の処理の負荷の影響で期待する時間でカウントアップが終了しない可能性がある
- 疑似要素で数字を表示しているため、素直にテキストのコピーができない
その他
数字の変更時のガタツキをなくす
数字が連続で変わっていくため、数字やフォントによってはガタツキが生じてしまう。
この事象を解消させるためには、対象の要素に CSS でfont-variant-numericプロパティを指定すると良い。
下記のような指定をすると、数字は数字を等幅で表示できるようになる。
font-variant-numeric: tabular-nums;
また、font-variant-numeric
プロパティは、プロポーショナルフォントと等幅フォントを混在させることもできる。
表示サンプル:
完成
CSSの方がパフォーマンスが良いため、@property
ルールが利用できるブラウザにはCSSを表示し、そうではないブラウザにはJavaScriptメインのコンポーネントを表示するようにした。
デモ
最後に
今回は、数字をカウントアップするシンプルなコンポーネントを作成した際のあれこれについて記載した。Reactコンポーネントで作成しているが、素の JavaScript でも考慮する点はあまり変わらないと思う。
Discussion