CSSのsin()とcos()で、要素を円の上に並べる方法

​​CSS​HTML,Playwright

CSS の sin() と cos() を使うと、要素を円の上に同じ間隔で並べられます。座標を JavaScript で計算したり、要素ごとに位置を書いたりしなくて済みます。要素の数が変わっても、CSS を書き直す必要はありません。

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)の値です。

点線の円の上に、1から8の番号の丸が同じ間隔で並んでいる。1は真上にあってピンク、2から8は水色で、時計回りに並ぶ
半径 130px の円に8個を並べた例。1個目が真上に来て、時計回りに並ぶ

円の上の位置は、半径に 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 で円の上へずらす

並べ方の考え方は、すべての要素をいったん円の中心に重ね、そこから角度ごとの位置へずらす、というものです。

html
<ul class="ring">
  <li>1</li>
  <li>2</li>
  <!-- … -->
  <li>8</li>
</ul>
css
.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から数えます。

html
<ul class="ring" style="--n: 8">
  <li style="--i: 0">1</li>
  <li style="--i: 1">2</li>
  <!-- … -->
</ul>
css
.ring > li {
  --angle: calc(360deg / var(--n) * var(--i) - 90deg);
}

検証環境では、sibling-index() を使った書き方と同じ位置に並びました。要素を足し引きしたときは、--i と --n も書き換える必要があります。

半円に並べるときは、両端に置くために1を引いた数で割る

円の一部に並べるときは、並べたい角度の範囲を、要素の数ではなく「要素の数 − 1」で割ります。1周では、最後の要素の次が1個目に戻るので、要素の数で割ります。半円では、始まりと終わりの両端に要素を置くので、間の数は要素の数より1つ少なくなります。

css
.half > li {
  --angle: calc(180deg / (sibling-count() - 1) * (sibling-index() - 1) + 180deg);
}

180deg(左)から始めて、時計回りに 360deg(右)まで進むので、円の上半分に並びます。5個で試したところ、1個目が左端、3個目が真上、5個目が右端に来て、45 度おきに並びました。

点線の半円の上に、1から5の番号の丸が並んでいる。1は左端にあってピンク、3が真上、5が右端
180deg から 360deg までの半円に5個を並べた例

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 で角度として登録しておきます。

css
@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 を渡す書き方を使います。

​Posted by 86