CSSで、読み込み中に回るアイコンを作る方法

​​CSS​HTML,Playwright,アクセシビリティ

読み込み中に回るアイコンは、丸い要素の枠線のうち上の1辺だけ色を変え、@keyframes で rotate(360deg) まで回し続けると作れます。画像もJavaScriptも要りません。ただし、linear を書き忘れると1周ごとに止まりかけ、span で作るときに display: inline-block を書き忘れると回りません。読み上げ用の文字と、動きを減らす設定への対応もあわせて紹介します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS だけのページを Playwright で開き、アニメーションを一時停止して時間を指定し、getComputedStyle() の transform から角度を求めました。Firefox と Safari、実際のスクリーンリーダーでの読み上げは試していません。

枠線の1辺だけ色を変えて、回し続ける

読み込み中の HTML は、回るアイコンと、読み上げ用の文字を role="status" の要素で包んだ形です。中身をいつ入れるかと、読み上げについては、後の節で書きます。

html
<div class="loading" role="status">
  <span class="spinner" aria-hidden="true"></span>
  <span class="visually-hidden">読み込み中</span>
</div>
css
.spinner {
  display: inline-block; /* span でも幅・高さと回転が効くようにする */
  width: 40px;
  height: 40px;
  border: 4px solid #cdeef5; /* 輪の薄い部分 */
  border-top-color: #00c8e8; /* 上の4分の1だけ濃い色にする */
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* 動きを減らす設定の人には、ゆっくり回す */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation-duration: 3s;
  }
}

/* 画面には出さず、読み上げには使われる文字 */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

border-radius: 50% で四角を輪にし、border-top-color で上の1辺だけ濃い色にしています。輪の4分の1だけが濃い色になり、それを回転させると、色の付いた部分が輪に沿って回って見えます。

@keyframes には to(終わり)しか書いていません。MDN の @keyframes のページにあるとおり、始まり(from)を書かないと、ブラウザーはその要素のもとのスタイルを始まりとして使います。Chromium で、このアニメーションのキーフレームを読み出すと、始まりは transform: none、終わりは rotate(360deg) になっていました。繰り返しの回数(animation-iteration-count)に infinite を指定しているので、1周したらまた 0 度から回り始めます。

1周を 1秒にしたアイコンで、時間ごとの角度を測りました。

経過時間 角度
0ms 0度
250ms 90度
500ms 180度
750ms 270度
999ms 359.6度

4分の1秒ごとに、ちょうど90度ずつ進んでいます。

linear を書かないと、1周ごとに止まりかける

animation に linear を書かないと、タイミング関数は初期値の ease になります。ease は、真ん中に向けて速くなり、終わりに向けて遅くなる動きです。animation: spin 1s infinite; と書いたアイコンと比べました。

上下に2つの回るアイコン。上の linear は一定の速さで回り続け、下の ease は色の付いた部分が上に戻るたびに遅くなって止まりかけ、また急に回り出す。周が変わると「2周目/3周」の文字がピンクになる
linear(上)と ease(下)のアイコンが3周する様子。実際の速さで再生している(上の経過時間、周の数、輪の上の短い線は GIF 用に足したもの)
経過時間 linear の角度 ease の角度
125ms 45度 49.3度
250ms 90度 147.1度
500ms 180度 288.9度
750ms 270度 345.8度
875ms 315度 356.7度

ease では、半分の 500ms までに 288.9度も回っています。一方、750ms から 1周の終わりまでの 250ms では、残りの約14度しか進みません。1周ごとにほとんど止まり、また急に回り出すので、回り方がぎくしゃくして見えます。

1周の間の、時間と角度のグラフ。linear は 0度から 360度までまっすぐな線で、ease は前半に急に上がり、750ms の時点で 345.8度に達して、その後はほとんど平らになる
50ms ごとに測った角度。水色が linear、灰色が ease。ピンクの点が 750ms の時点

同じ速さで回し続けたいアイコンには、linear を書きます。

span で作るなら display: inline-block にする

span はインラインレベルの要素です。同じクラスに display: inline を足して、インラインのままにした span を文の途中に置き、比べました。

display: inline のまま display: inline-block
幅 × 高さ 8px × 32px 48px × 48px
250ms の getComputedStyle() の transform matrix(0, 1, -1, 0, 0, 0) matrix(0, 1, -1, 0, 0, 0)
250ms の画面上の幅 × 高さ 8px × 32px 48px × 48px

インラインのままでは、width と height の 40px が効かず、幅は左右の枠線の 8px だけ、高さは 32px の、細い縦長の形になりました。さらに、transform は 90度の回転の値になっているのに、画面上の幅と高さは 8px × 32px のままでした。縦長の形が90度回れば、横長になるはずです。transform は置換要素でないインラインボックスには効かないので、値が入っていても回りません。

inline-block の方は正方形なので、90度回っても画面上の大きさは変わりません。回っていることは、上の節の角度と GIF で確かめています。大きさは 48px 四方になりました。幅と高さの 40px に、両側の枠線 4px ずつが足されて 48px になるためです。box-sizing の初期値 content-box では、width と height には枠線が含まれません。枠線も含めて 40px にしたいときは box-sizing: border-box を足します。足したアイコンは、40px × 40px になりました。

読み上げ用の文字は、role="status" の中に置く

回るアイコンそのものには、読み上げに使える文字がありません。そこで、画面には出さない「読み込み中」の文字を .visually-hidden で用意し、アイコンには aria-hidden="true" を付けて、読み上げの対象から外しています。

Playwright で、このページのアクセシビリティツリー(読み上げなどに使われる情報)を書き出すと、次のようになりました。

text
- status: 読み込み中

role="status" の要素が、「読み込み中」という名前でツリーに入っています。.visually-hidden の文字は、画面上では 1px × 1px の大きさでした。

MDN の status ロールのページによると、status はライブリージョンの一種で、暗黙に aria-live="polite" が設定されます。中身が変わったときに、利用者の操作をさえぎらずに読み上げられる、という扱いです。ただし、ライブリージョンについての MDN のページには、ライブリージョンは中身が変わる前からページにある必要があり、まず空のライブリージョンを作ってから中身を変える、とあります。最初から文字が入った要素を hidden で出し入れする作りでは、MDN の説明からすると、表示したときに読み上げられないおそれがあります。

空の role="status" を先に置き、読み込むときに中身を入れる

そこで、ページには空の role="status" の要素だけを最初から置いておきます。CSS は最初の節のものをそのまま使います。

html
<div class="loading" role="status"></div>

読み込みを始めるときに、アイコンと「読み込み中」の文字を中へ入れ、終わったら中身を空に戻します。role="status" の要素そのものは、ずっとページに残します。

js
const loading = document.querySelector('.loading');

// 読み込みを始めるとき:空の role="status" の中へ、アイコンと文字を入れる
function showLoading() {
  const spinner = document.createElement('span');
  spinner.className = 'spinner';
  spinner.setAttribute('aria-hidden', 'true');

  const text = document.createElement('span');
  text.className = 'visually-hidden';
  text.textContent = '読み込み中';

  loading.replaceChildren(spinner, text);
}

// 読み込みが終わったとき:中身を空に戻す(role="status" の要素は残す)
function hideLoading() {
  loading.replaceChildren();
}

showLoading() を呼んだ後の中身は、最初の節の HTML と同じ形になります。

Playwright で、それぞれの時点のアクセシビリティツリーとアイコンのアニメーションを調べました。Chromium が status の要素に持たせている live の値も、Chrome DevTools Protocol で読み出しています。

時点 アクセシビリティツリー live の値 アイコンのアニメーション
ページを開いた直後 - status polite なし(アイコンがない)
showLoading() の後 - status: 読み込み中 polite 1つ(1周 1秒)
hideLoading() の後 - status polite なし(アイコンがない)
もう一度 showLoading() の後 - status: 読み込み中 polite 1つ(経過時間 0ms)

status の要素は、ページを開いた直後から空のままツリーに入っていて、live は polite でした。中身を入れると「読み込み中」という名前が付き、空に戻すと名前のない status に戻ります。どの時点でも、status の要素がツリーから消えることはありません。中身を空にするとアイコンの要素ごとなくなるので、見えないところで回り続けることもありません。もう一度入れたアイコンは、0ms から回り始めました。

包む要素ごと hidden で隠すと、status がツリーから消える

比べるために、最初から文字が入った要素を、包んでいる要素ごと hidden で出し入れする作りも調べました。

js
document.querySelector('.loading').hidden = true;

hidden 属性の要素は、ブラウザーが display: none で隠せることになっています。Chromium でも display は none になりました。display: none になった要素の中では、アイコンのアニメーションもなくなりました。

ただし、hidden を付けている間は、status の要素もアクセシビリティツリーから消えます。hidden を外すと、status は「読み込み中」の文字が入った状態でツリーに戻りました。MDN の説明にある「先に空のライブリージョンを置いておく」形にはなりません。読み込み中の表示を消すときは、包む要素を隠すのではなく、前の節のように中身を空にします。

動きを減らす設定では、ゆっくり回す

OS には、アニメーションを減らすための設定があります(Windows 11 の「アニメーション効果」、macOS の「動きの抑制」など)。これがオンになっているかどうかは、CSS の prefers-reduced-motion で調べられます。

この記事の CSS では、reduce のときに animation-duration を 3s にしています。Playwright で設定を切り替えてページを開くと、次のようになりました。

動きを減らす設定 1周にかかる時間
オフ(no-preference) 1秒
オン(reduce) 3秒

この記事で止めずに遅くしているのは、止まった輪だけでは、読み込み中なのか、ただの飾りなのかが見た目で分からないからです。

動きをなくすなら、「読み込み中」の文字を画面に出す

動きを減らす設定のときにアイコンを止めたい場合は、止めたうえで「読み込み中」の文字を画面に出すと、止まった輪だけになるのを避けられます。最初の節の CSS の後に、次の指定を足します。最初の節にある animation-duration: 3s の指定は、残したままでかまいません。

css
/* 動きを減らす設定の人には、止めて文字を出す */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation: none;
  }

  .loading .visually-hidden {
    position: static;
    width: auto;
    height: auto;
    margin: 0;
    overflow: visible;
    clip-path: none;
  }
}

.visually-hidden の文字を、.loading の中にあるときだけ画面に出る普通の文字に戻しています。前の節の showLoading() で中身を入れてから、設定を切り替えて調べました。

動きを減らす設定 アイコンのアニメーション 「読み込み中」の文字 アクセシビリティツリー
オフ(no-preference) 1つ 1px × 1px(画面には出ない) - status: 読み込み中
オン(reduce) なし(animation-name は none) position: static になり、81px × 17px で画面に出る - status: 読み込み中

設定がオンのときは、アイコンは回らずに止まった輪になり、「読み込み中」の文字が画面に出ます。読み上げ用の名前は、どちらの設定でも「読み込み中」のままでした。

​Posted by 86