CSSで、読み込み中に回るアイコンを作る方法
読み込み中に回るアイコンは、丸い要素の枠線のうち上の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" の要素で包んだ形です。中身をいつ入れるかと、読み上げについては、後の節で書きます。
<div class="loading" role="status">
<span class="spinner" aria-hidden="true"></span>
<span class="visually-hidden">読み込み中</span>
</div>
.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; と書いたアイコンと比べました。

| 経過時間 | 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周ごとにほとんど止まり、また急に回り出すので、回り方がぎくしゃくして見えます。

同じ速さで回し続けたいアイコンには、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 で、このページのアクセシビリティツリー(読み上げなどに使われる情報)を書き出すと、次のようになりました。
- status: 読み込み中
role="status" の要素が、「読み込み中」という名前でツリーに入っています。.visually-hidden の文字は、画面上では 1px × 1px の大きさでした。
MDN の status ロールのページによると、status はライブリージョンの一種で、暗黙に aria-live="polite" が設定されます。中身が変わったときに、利用者の操作をさえぎらずに読み上げられる、という扱いです。ただし、ライブリージョンについての MDN のページには、ライブリージョンは中身が変わる前からページにある必要があり、まず空のライブリージョンを作ってから中身を変える、とあります。最初から文字が入った要素を hidden で出し入れする作りでは、MDN の説明からすると、表示したときに読み上げられないおそれがあります。
空の role="status" を先に置き、読み込むときに中身を入れる
そこで、ページには空の role="status" の要素だけを最初から置いておきます。CSS は最初の節のものをそのまま使います。
<div class="loading" role="status"></div>
読み込みを始めるときに、アイコンと「読み込み中」の文字を中へ入れ、終わったら中身を空に戻します。role="status" の要素そのものは、ずっとページに残します。
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 で出し入れする作りも調べました。
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 の指定は、残したままでかまいません。
/* 動きを減らす設定の人には、止めて文字を出す */
@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: 読み込み中 |
設定がオンのときは、アイコンは回らずに止まった輪になり、「読み込み中」の文字が画面に出ます。読み上げ用の名前は、どちらの設定でも「読み込み中」のままでした。





