Edited at

CSS と SVG でドーナツ作り

最近ドーナツ型の Loader とか Progress bar 、あるいは Donut Chart を良く見かけますが、作ったことがないので作ってみました。

ただし今回は下のような単なる Loader を想定しているので、進捗状況に応じてバーが伸縮するようなものをお探しの方はライブラリに頼った方が良いと思います。

なお、動くデモはこちらです。そしてこの記事は深夜のテンションで書いちゃってるのでおかしなところがあってもスルーしてくださいmm

Nov-17-2018 00-01-54.gif


まずは CSS だけで作ってみる

どうせ SVG で作るのが最適解なんでしょうが、 CSS だけで作るとどんなもんなんでしょう?

<style>

.donut-1 {
animation: donut-1-animation 1000ms ease-in-out infinite;
background-color: rgba(255, 255, 255, 0.75);
border-radius: 100%;
overflow: hidden;
position: relative;
width: 128px;
height: 128px;
}
@keyframes donut-1-animation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.donut-1:before {
border-top: 64px solid #0080f0;
border-right: 64px solid transparent;
border-left: 64px solid transparent;
content: "";
display: block;
}
.donut-1:after {
background-color: #e0e0e0;
border-radius: 100%;
content: "";
display: block;
margin: auto;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 112px;
height: 112px;
}
</style>
<div class="donut-1"></div>

長い!しかもドーナツの中心がベタ塗りなので背景の斜線が見えません(デモの一番上)。これでは画面中央にオーバーレイしても背後の UI が隠れてしまいます。プロダクトでは使えませんね。

ちなみに青いバーの部分は CSS Triangle です。なのでバーの長さを細かく調節することはできません。ここは妥協。


マスクしてみる

ベタ塗り部分を透過させるためにマスクしてみました。

clip-path はパスの内側だけ表示するものなので、今回は画像の非透過部分だけ表示する mask を使います(厳密には clip-path でもパスの向きを逆にすればパスの外側だけを表示できるんですが、今回のような同心円状のマスクは難しいようです)。

<style>

.donut-2 {
animation: donut-2-animation 1000ms ease-in-out infinite;
background-color: rgba(255, 255, 255, 0.75);
border-radius: 100%;
-webkit-mask: radial-gradient(closest-side, transparent 87.5%, #000000 87.5%);
mask: radial-gradient(closest-side, transparent 87.5%, #000000 87.5%);
position: relative;
width: 128px;
height: 128px;
}
@keyframes donut-2-animation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.donut-2:before {
border-top: 64px solid #0080f0;
border-right: 64px solid transparent;
border-left: 64px solid transparent;
content: "";
display: block;
}
</style>
<div class="donut-2"></div>

良い感じになりました。これはこれでアリかな…ただし Edge は radial-gradient の Gradient Midpoints に対応していないらしく、動作しませんでした。まぁいいや、 SVG は大丈夫だろうから(適当)。

あと radial-gradient ではバーの幅を正確に指定できませんが、見た目違和感ないので良しとします。


マスクを SVG で作ってみる

mask の値を SVG にしてデザインの拡張性を高めてみるのはどうでしょう?

-webkit-mask: url('data:image/svg+xml,<svg style="fill: none; stroke: #000000; width: 64px; height: 64px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" stroke-width="4" /></svg>');

mask: url('data:image/svg+xml,<svg style="fill: none; stroke: #000000; width: 64px; height: 64px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" stroke-width="4" /></svg>');

Chrome と Safari では上手く機能しますが、 Edge と Firefox ではダメでした。

Edge での原因は不明。 Firefox では mask は機能しているようなんですが、おそらく Data URI 化した SVG が認識されないようです。もしかしたらちゃんと Base64 でエンコードすれば機能するのかもしれませんが、そうすると視認性が皆無に…あ、 SVG タグを外に出して url('#path'); みたいにすれば機能するのかもしれません。ちょっと今回やっつけなので試してませんが、コード量も減るのでそうした方が良いでしょう。


というわけで全部 SVG で作ってみる

こうなりました。

<style>

.donut-4 {
animation: donut-4-animation 1000ms ease-in-out infinite;
width: 128px;
height: 128px;
}
@keyframes donut-4-animation {
0% {
transform: rotate(-45deg);
}
100% {
transform: rotate(315deg);
}
}
.donut-4 circle {
fill: none;
stroke: rgba(255, 255, 255, 0.75);
}
.donut-4 path {
fill: none;
stroke: #0080f0;
}
</style>
<svg class="donut-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="30" stroke-width="4" />
<path d="M32,2 a30,30,0,0,1,30,30" stroke-width="4" />
</svg>

これは問題なく動作しました。ただ…

path タグの d 属性でパスを設定するんですが、円弧を表現する a/A の書き方が面倒臭すぎるんですよね。 Canvas の arc 関数みたいにしてくれたら楽なのに、始点と終点の座標を指定してあげないといけない、つまり計算する必要があるんです。実際 Twitter でも相当細かい実数が指定されていました。ツールを使う手もあるんですが、今回は 90 度の円弧で良いので使わずに済ませましょう。これで我慢してください。

あともうひとつ面倒なのが、 stroke-width 。 SVG の stroke はパスを中心にして両側に描画されるため、描画範囲ギリギリにパスを引くと stroke がはみ出してしまう、なのでその分を計算してパスを小さく取らないといけない。このあたり上手いやり方があると助かるんですが…

ちなみに fill とか stroke をスタイルシートに書いているのは、なるべくデザインパラメータを CSS で制御しようという思惑です。


結論、ドーナツはできるだけ SVG で作ろう、ということになりました。当然ですけど…ただ、世の中には SVG ドーナツのレシピがたくさんあるようなので(だいぶトリッキーなものばかりですが)、実装する際は色々試してみるのが良いと思います。

なお今回のコードはこちらです。おまけとして Canvas での描画例もありますが、気にしないでください。