CSSのsin()とcos()で、要素を円の上に並べる方法
CSS の sin() と cos() を使うと、要素を円の上に同じ間隔で並べられます。座標を JavaScript で計算したり、要素ごとに位置を書いたりしなくて済みます。要素の数が変わっても、CSS を書き直す必要はありません。
.ring > li {
--angle: calc(360deg / sibling-count() * (sibling-index() - 1) - 90deg);
position: absolute;
left: 50%;
top: 50%;
translate: calc(cos(var(--angle)) * var(--r)) calc(sin(var(--angle)) * var(--r));
}
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。ページを Playwright で開き、getBoundingClientRect() で各要素の中心の位置を測りました。Firefox や Safari など、Chromium 以外のブラウザーでの表示は試していません。各ブラウザーの対応バージョンは、互換性データ(@mdn/browser-compat-data 8.1.8)の値です。

円の上の位置は、半径に cos() と sin() を掛けて求める
円の中心から角度 θ の方向に、半径 r だけ離れた点は、横に r × cos(θ)、縦に r × sin(θ) の位置にあります。CSS の cos() と sin() は、この cos(θ) と sin(θ) を返す関数です。
返ってくるのは −1 から 1 までの、単位のない数です。長さにするには、px などの長さを掛けます。検証環境で width に入れて試した結果です。
| 書き方 | width の計算値 |
|---|---|
calc(sin(30deg) * 100px) |
50px |
calc(cos(60deg) * 100px) |
50px |
calc(sin(0.25turn) * 100px) |
100px |
calc(sin(pi / 2) * 100px) |
100px |
calc(sin(1) * 100px) |
84.1406px |
角度は deg のほかに turn や rad でも書けます。単位のない数は、ラジアンとして扱われます。sin(1) は 1 ラジアン(約57度)の sin なので、約 0.841 です。pi は円周率を表すキーワードで、sin(pi / 2) は sin(90deg) と同じ 1 になりました。
width: 33px; のあとに width: sin(30deg); と書くと、2つ目の宣言は無効になり、幅は 33px のままでした。単位のない数は width の値にならないので、必ず長さを掛けます。
中心に重ねてから、translate で円の上へずらす
並べ方の考え方は、すべての要素をいったん円の中心に重ね、そこから角度ごとの位置へずらす、というものです。
<ul class="ring">
<li>1</li>
<li>2</li>
<!-- … -->
<li>8</li>
</ul>
.ring {
--r: 120px; /* 円の半径 */
--size: 48px; /* 並べる要素の大きさ */
position: relative;
width: 300px;
height: 300px;
}
.ring > li {
--angle: calc(360deg / sibling-count() * (sibling-index() - 1) - 90deg);
position: absolute;
left: 50%;
top: 50%;
width: var(--size);
height: var(--size);
margin: calc(var(--size) / -2);
translate: calc(cos(var(--angle)) * var(--r)) calc(sin(var(--angle)) * var(--r));
}
left: 50% と top: 50% で、要素の左上の角が親の中心に来ます。そこから負の margin で大きさの半分だけ戻すと、要素の中心が親の中心に重なります。最後に translate で、横に cos() × 半径、縦に sin() × 半径 だけずらします。
8個の li で試したところ、どの要素も、中心から 120.00px の位置に 45 度おきに並びました。親の中心から見た、各要素の中心の位置です(右と下が正)。
| 何個目 | 横 | 縦 |
|---|---|---|
| 1 | 0.00px | −120.00px |
| 2 | 84.85px | −84.85px |
| 3 | 120.00px | 0.00px |
| 4 | 84.85px | 84.85px |
| 5 | 0.00px | 120.00px |
| 6 | −84.85px | 84.85px |
| 7 | −120.00px | 0.00px |
| 8 | −84.85px | −84.85px |
真上から並べるには、角度から 90deg を引く
数学のグラフでは上が正ですが、CSS の座標は下が正です。そのため、角度 0deg は右(3時の方向)で、角度が増えると時計回りに進みます。
- 90deg を書かずに4個並べると、1個目は右、2個目は下、3個目は左、4個目は上に来ました。時計の文字盤やメニューのように、1個目を真上(12時の方向)に置きたいときは、上の例のように角度から 90deg を引きます。
何番目かと兄弟の総数は、sibling-index() と sibling-count() で取る
隣どうしの要素の間の角度は、360deg を要素の数で割った値です。何番目かは sibling-index() で、兄弟の総数は sibling-count() で取れます。sibling-index() は1から数えるので、1個目の角度が 0 になるように 1 を引いています。
要素の数が変わると、間隔も自動で変わります。8個のリストに JavaScript で li を1個足すと、9個が 40 度おきに並び直しました。そこから3個消すと、6個が 60 度おきになりました。どちらも1個目は真上のままでした。
sibling-count() は、同じ親を持つすべての要素を数えます。円の中央に置く見出しなどを同じ親の中に書くと、それも1個として数えられ、間隔がずれます。検証環境で、並べる要素4個のあとに <p> を1個置いたところ、4個は 90 度おきではなく 72 度おき(5等分)に並び、円の左側が大きく空きました。並べる要素だけを1つの親に入れ、ほかの要素はその外に置きます。
sibling-index() と sibling-count() に対応していないブラウザーでは、何番目かと兄弟の総数を、HTML の style 属性でカスタムプロパティとして渡す書き方があります。この場合は、--i を0から数えます。
<ul class="ring" style="--n: 8">
<li style="--i: 0">1</li>
<li style="--i: 1">2</li>
<!-- … -->
</ul>
.ring > li {
--angle: calc(360deg / var(--n) * var(--i) - 90deg);
}
検証環境では、sibling-index() を使った書き方と同じ位置に並びました。要素を足し引きしたときは、--i と --n も書き換える必要があります。
半円に並べるときは、両端に置くために1を引いた数で割る
円の一部に並べるときは、並べたい角度の範囲を、要素の数ではなく「要素の数 − 1」で割ります。1周では、最後の要素の次が1個目に戻るので、要素の数で割ります。半円では、始まりと終わりの両端に要素を置くので、間の数は要素の数より1つ少なくなります。
.half > li {
--angle: calc(180deg / (sibling-count() - 1) * (sibling-index() - 1) + 180deg);
}
180deg(左)から始めて、時計回りに 360deg(右)まで進むので、円の上半分に並びます。5個で試したところ、1個目が左端、3個目が真上、5個目が右端に来て、45 度おきに並びました。

transform を空けておくと、拡大しても位置がずれない
位置を決めるのに、transform ではなく translate プロパティを使っています。translate プロパティは、transform プロパティとは別に指定できます。
位置を transform: translate(…) で決めていると、ホバーなどで transform: scale(1.5) を指定したときに、位置の指定まで上書きされます。検証環境で試すと、1個目は 72px に大きくなると同時に、円の中心に戻ってしまいました。
translate プロパティで位置を決めておけば、transform は自由に使えます。同じように1個目に transform: scale(1.5) を指定すると、大きさは 48px から 72px になり、中心の位置は真上の 120px のままでした。
円の上を回転させるなら、角度を @property で登録する
角度にずらす量の --offset を足し、--offset を @keyframes で 0deg から 360deg まで変えると、要素が円の上を回ります。このとき、--offset を @property で角度として登録しておきます。
@property --offset {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
.ring {
animation: spin 8s linear infinite;
}
@keyframes spin {
to { --offset: 360deg; }
}
@media (prefers-reduced-motion: reduce) {
.ring { animation: none; }
}
.ring > li {
--angle: calc(360deg / sibling-count() * (sibling-index() - 1) - 90deg + var(--offset));
}
1周8秒のアニメーションを、animation に paused を付け、負の animation-delay で途中の時点に止めて、1個目の位置を測りました。@property で登録した場合は、1秒で --offset が 45deg になり、真上から右上へ 45 度進みました。2秒で 90deg になり、右(3時の方向)に来ました。
@property を書かない場合は、要素は真上から動きませんでした。登録していない --offset は途中の値にならず、4秒(半分)を境に 0deg から 360deg へ一度に切り替わりました。360deg は1周して同じ位置なので、見た目は止まったままです。
動きを減らす設定(prefers-reduced-motion: reduce)を Playwright で再現した場合は、animation: none が効き、どの時点でも1個目は真上にありました。
ブラウザーの対応状況
互換性データでの対応状況は次のとおりです。
| ブラウザー | sin()・cos() |
sibling-index()・sibling-count() |
@property |
|---|---|---|---|
| Chrome・Edge(Android の Chrome を含む) | 111 から | 138 から | 85 から |
| Firefox(Android を含む) | 108 から | 154 から | 128 から |
| Safari(iOS を含む) | 15.4 から | 26.2 から | 16.4 から |
sin() と cos() に比べて、sibling-index() と sibling-count() は新しい機能です。古いブラウザーにも対応させたいときは、style 属性で --i と --n を渡す書き方を使います。





