廣瀬製紙株式会社

Employees' Blog 社員ブログ

ライブラリなしの円形スライダーを、慣性も自動回転も CSS だけで回す
全 3 回・第 1 回。CSS の transform 補間が円周上の 2 点を弦で結ぶ仕組みと、扇型・観覧車型の 2 パターンの作り方

公開日: 2026.08.07 更新日: 2026.08.07
円周上に並んだスライドが、片側では正しく円弧を描き、もう片側では直線で結ばれて内側にたわんでいる対比を描いたヒーロー挿絵。「円は、どこで直線になるのか」という文字が入っている

画像を円弧に沿って並べて、マウスでぐるぐる回せるスライダー。観覧車のようにも、扇のようにも見えるあれです。デザインの引き出しとしては定番なのに、いざ作ろうとすると、既製のスライダーライブラリの守備範囲から少しだけ外れています。

円周に並べるところまでは、驚くほど簡単です。 三角関数を1回使えば終わります。ところが、指を離したあとに惰性で回し続けようとした瞬間、円が音を立てて崩れます。スライドが内側に吸い込まれ、また外へ戻ってくる。ゴムでできた輪っかを振り回しているような、あの気持ち悪い動きです。

この記事は、その崩れがどこから来るのかを突き止めて、JavaScript を毎フレーム走らせずに直すところまでを扱います。ライブラリは使いません。全3回の1回目で、この回で「円運動をどう表現するか」という土台を全部説明します。実際に触れるデモを3つ埋めてあるので、読みながら回してみてください。

まずは素直に作ってみます

円周上に等間隔で並べるだけなら、考えることはほとんどありません。半径を決めて、i 番目のスライドの角度を 360 度 ÷ 枚数 × i とし、そこから x と y を出します。

const RADIUS = 420;
const COUNT = 14;

function layout(ringAngle) {
  items.forEach((el, i) => {
    const deg = ringAngle + (i * 360) / COUNT;
    const rad = (deg * Math.PI) / 180;
    const x = RADIUS * Math.sin(rad);
    const y = -RADIUS * Math.cos(rad);
    el.style.transform = `translate(${x}px, ${y}px) rotate(${deg}deg)`;
  });
}

translate でその位置へ運び、rotate でスライド自身を円に沿って傾けています。あとはドラッグに合わせて ringAngle を増やしながら layout() を呼べば、指の動きにぴったり追従して回ります。ここまでは何の問題もありません。

問題が出るのは、指を離したあとです。勢いよく振ったぶんだけ惰性で回ってほしいので、ここは CSS のトランジション、つまり「始まりと終わりの値を渡すと途中をブラウザが埋めてくれる仕組み」に任せて、最終的な角度を一度だけ書き込みます。ついでに出てくる cubic-bezier(...) は、その埋め方の速さの変化を決めるもので、イージングと呼ばれます。ここでは「最初は速く、だんだん減速する」カーブを指定していて、惰性らしさはこの部分が担当します。

items.forEach((el) => {
  el.style.transition = 'transform 1.2s cubic-bezier(0.22, 1, 0.36, 1)';
});
layout(ringAngle + glide);

書き味としては、これ以上ないくらい素直です。ところが動かすと、円が縮んで戻ります。

補間、という言葉

原因に入る前に、この記事で何度も出てくる言葉をひとつ決めておきます。補間は、始まりの値と終わりの値だけを渡されたブラウザが、その途中をどう埋めるかという話です。

opacity を 0 から 1 へ動かすなら、途中は 0.5 だろうと誰でも予想できます。では transform: translate(0px, -420px) rotate(0deg) から transform: translate(363px, 210px) rotate(120deg) へ動かすとき、真ん中はどんな値になるのでしょうか。

ここが今回の分かれ道です。仕様は、この埋め方をかなり細かく決めています。

ブラウザは transform をどう埋めているのか

CSS Transforms の仕様には、大きく2つの経路が書かれています。

ひとつ目は、両端の関数の並びが揃っている場合です。translate と translate、rotate と rotate のように、対応する位置に同じ種類の関数が来ていれば、仕様は「対応する関数のペアを補間する」と定めています。つまり translate は translate どうし、rotate は rotate どうしで、それぞれ独立に数値が動きます。

ふたつ目は、並びが違う場合です。このときは両端をそれぞれ 4×4 の行列にまとめ、その行列を分解して、平行移動・回転・拡大といった成分ごとに補間してから組み直します。

さて、ここで最初の問いです。translate(0px, -420px) から translate(363px, 210px) へ、x と y がそれぞれ独立に動くとき、その点はどんな線を描くでしょうか。

直線です。x も y も等速で増えるので、2点を最短で結ぶ線分になります。円周上の2点を線分で結んだものには名前があって、弦と呼びます。円弧のほうが外側に膨らんでいるぶん、弦は必ず内側を通ります。

円周上の2点を、外側にふくらむ円弧と、内側をまっすぐ横切る弦の2通りで結んだ図。弦の中点が円の内側に落ち込んでいることが示されている
図 1: 円周上の2点を x と y で結ぶと、円弧ではなく弦を通る

どのくらい内側に落ちるのか

「内側を通る」だけでは、気にすべきかどうか分かりません。そこで実際に測りました。半径 420px の円で、始点と終点の角度差を変えながら、補間のちょうど真ん中で中心からの距離がいくつになるかを見ています。

以下は Chrome 151 での実測値です。実時間に左右されないよう、アニメーションを一時停止して途中の状態を直接読み出しています。

送る角度中点での半径落ち込み割合
25.714 度(14 枚なら 1 枚ぶん)409.47px10.53px2.5%
45 度388.03px31.97px7.6%
90 度296.98px123.02px29.3%
180 度0px420px100%

1 枚ぶん送るだけなら 2.5%、目を凝らせば分かる程度です。ところが勢いよく振って半周ぶん回ると、落ち込みは 100%、つまりスライドはいったん円の中心をぴったり通過します。14 枚全部が中心に集まって、また散っていく。冒頭の「ゴムの輪っか」の正体はこれでした。

しかも厄介なことに、ドラッグしている間は毎フレーム layout() を呼んでいるので、軌跡はきれいな円のままです。指を離した瞬間だけ形が変わる。 原因を探しにくいのは、この不連続さのせいです。

ここまでは数値の話でした。下のデモで、位置を x と y で持つ実装と、角度で持つ実装を切り替えてみてください。 勢いよく回して指を離すと、直交座標版だけ円がつぶれて戻ります。図 1 の弦が、動くとどう見えるかがそのまま出ます。

デモ 1: 同じ操作を、位置を x と y で持つ実装と、角度で持つ実装で見比べる

直し方は、座標の持ち方を変えることです

ここまでの話を裏返すと、直し方が見えてきます。ブラウザは「与えられた量を線形に埋める」だけで、悪いことは何もしていません。線形に埋めると円にならない量を渡している、こちらの側に原因があります。

では、線形に埋めると円になる量は何でしょうか。角度です。角度を等速で増やせば、その点は円周上を等速で進みます。

そして CSS には、まさにそれを表す書き方があります。

transform: rotate(30deg) translateY(-420px);

「30 度回してから、そこから 420px ぶん腕を伸ばす」ということです。位置は x と y ではなく、角度と腕の長さで決まっています。腕の長さは動かさず、角度だけを動かせば、ブラウザが補間するのは角度だけです。

同じ 2 点を 2 通りの書き方で結んで、途中を測ってみました。半径 150px の円で、0 度から 90 度へ動かしています。

translate で持った場合                rotate で持った場合
 t=0     中心からの距離 150            t=0     150
 t=0.25  118.59                        t=0.25  150
 t=0.5   106.07   ← 44px 内側          t=0.5   150
 t=0.75  118.59                        t=0.75  150
 t=1     150                           t=1     150

右側は、途中のどこを取っても 150 のままです。 円が円のまま回るのに、特別な仕掛けは何も要りませんでした。

左右の対比図。左は位置を「xとy」で持つのでブラウザが埋めるのは2つの長さ、右は位置を「角度と腕の長さ」で持つのでブラウザが埋めるのは角度だけ、という違いを示す
図 2: 同じ場所を指していても、ブラウザが埋める量が違う

3つの角度を足し算にする

方針が決まったので、組み立てに入ります。回転させたいものは、実は3種類あります。

ひとつ目は並べるための角度で、i 番目のスライドを 360 度 ÷ 枚数 × i だけずらすものです。これは一度決めたら動きません。ふたつ目は手で回した角度で、ドラッグと慣性で動きます。3つ目は自動回転の角度で、放っておくとゆっくり回り続けるぶんです。

うれしいことに、同じ中心まわりの回転どうしは、ただの足し算になります。順番を気にする必要もありません。ですから、この3つを別々に持っておいて、最後に足せば済みます。

並べるための角度、手で回した角度、自動回転の角度という3つの回転が、同じ中心のまわりで重ね合わされて1枚のスライドの位置を決めることを示す図
図 3: 3つの角度が重なって、1枚のスライドの位置が決まる

構造もそのまま素直に書けます。円の中心に大きさゼロの要素を置き、そこにスライドをぶら下げます。

<div class="arc-slider">
  <ul class="arc-ring">
    <li class="arc-item" style="--i: 0"><div class="arc-face">1</div></li>
    <li class="arc-item" style="--i: 1"><div class="arc-face">2</div></li>
    <li class="arc-item" style="--i: 2"><div class="arc-face">3</div></li>
    <!-- 以下、枚数ぶん同じ形で並べ、--i を 0 から順に振ります -->
  </ul>
</div>

--i は「何番目か」を CSS へ渡すためだけの値です。中身は画像でも文章でも構いません。枚数を変えたら、あとで出てくる --count も同じ数に直してください。 ここがずれると、円周に隙間が空いたり重なったりします。

角度を、アニメーションできる値としてブラウザに教える

手で回した角度をどこに持たせるかを決めます。JavaScript から書き換えたいので、-- で始まる自前の変数、いわゆるカスタムプロパティが素直です。

.arc-ring {
  transform: rotate(var(--ring-angle));
}

ここで一度立ち止まります。この --ring-angle を JavaScript が書き換えたとき、トランジションはちゃんと走るのでしょうか。

走ります。実際に確かめたところ、transitionrun が transform に対して発火し、途中の半径も 420px のまま保たれていました。円がなめらかに回るだけなら、これで足りています。

ところが自動回転を CSS のアニメーションで回そうとすると、様子が変わります。カスタムプロパティを @keyframes で 0 度から 120 度へ動かして、途中を測った結果がこれです。

f=0     x=0      y=-420
f=0.25  x=0      y=-420
f=0.49  x=0      y=-420
f=0.51  x=363.7  y=210     ← ここで一気に飛ぶ
f=0.75  x=363.7  y=210
f=1     x=363.7  y=210

途中が、存在していません。 49% までは始点に居座り、50% を過ぎた瞬間に終点へワープします。ふつうのカスタムプロパティは、ブラウザにとってはただの文字列で、角度として解釈されていないからです。文字列は「半分だけ混ざった状態」を作れないので、途中で入れ替わるしかありません。

これを解決するのが @property です。カスタムプロパティに型を教えると、ブラウザはそれを角度として扱い、補間できるようになります。

@property --ring-angle {
  syntax: "<angle>";
  inherits: true;
  initial-value: 0deg;
}

同じアニメーションを、登録したプロパティで測り直すとこうなります。

f=0     x=0      y=-420
f=0.25  x=210    y=-363.7
f=0.49  x=359.3  y=-217.6
f=0.51  x=368    y=-202.3
f=0.75  x=420    y=0
f=1     x=363.7  y=210

途中が全部埋まり、しかもすべての点が半径 420px の円周上に乗っています。@property は 2024 年 7 月に主要ブラウザで足並みが揃った、比較的新しい書き方です。

CSS 全文

道具が揃ったので、まとめて書きます。ここから先はコピーして貼るだけで動きます。

@property --ring-angle {
  syntax: "<angle>";
  inherits: true;
  initial-value: 0deg;
}
@property --spin-angle {
  syntax: "<angle>";
  inherits: true;
  initial-value: 0deg;
}

.arc-slider {
  --radius: 420px;        /* 円の半径。大きいほどゆるい弧になります */
  --count: 14;            /* スライドの枚数。li の数と必ず合わせます */
  --size: 120px;          /* スライド1枚の大きさ */
  --spin-duration: 60s;   /* 自動回転で1周する時間 */
  --glide-duration: 1.2s; /* 指を離してから止まるまでの時間 */

  position: relative;
  width: 100%;
  max-width: 760px;
  height: 300px;
  margin-inline: auto;
  overflow: hidden;
  touch-action: none;     /* 指の動きをブラウザのスクロールに取られないように */
  cursor: grab;

  animation: arc-spin var(--spin-duration) linear infinite;
  transition: --ring-angle var(--glide-duration) cubic-bezier(0.22, 1, 0.36, 1);
}

.arc-slider.is-dragging {
  cursor: grabbing;
  animation-play-state: paused;  /* 掴んでいる間は自動回転を止めます */
  transition: none;              /* 指にはトランジション抜きで即座に追従させます */
}

@keyframes arc-spin {
  to { --spin-angle: 360deg; }
}

/* 円の中心に置く、大きさゼロの要素。ここが回転の軸になります */
.arc-ring {
  position: absolute;
  left: 50%;
  top: calc(var(--radius) + var(--size) / 2 + 12px);
  width: 0;
  height: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  transform: rotate(calc(var(--spin-angle) + var(--ring-angle)));
}

/* 角度で位置を決めます。三角関数はどこにも出てきません */
.arc-item {
  --item-angle: calc(var(--i) * 360deg / var(--count));
  position: absolute;
  width: var(--size);
  height: var(--size);
  margin: calc(var(--size) / -2);
  transform: rotate(var(--item-angle)) translateY(calc(var(--radius) * -1));
}

.arc-face {
  width: 100%;
  height: 100%;
  border-radius: 12px;
  overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
  .arc-slider { animation: none; }
}

.arc-ring の top に足している var(--size) / 2 + 12px は、いちばん上のスライドが枠の上端で切れないようにするための余白です。12px を増やせば、弧全体が下がります。

JavaScript 全文

JavaScript の仕事は、ドラッグしている間に角度を1つ書き換えることだけです。

const slider = document.querySelector('.arc-slider');
const ring = slider.querySelector('.arc-ring');

let angle = 0;      // 手で回した角度。0〜360 に丸めず、ひたすら足していきます
let dragging = false;
let startPointer = 0;
let startAngle = 0;
let last = { a: 0, t: 0 };
let velocity = 0;   // 度 / ミリ秒

// .arc-ring は大きさゼロなので、その位置がそのまま円の中心になります
function pointerAngle(ev) {
  const c = ring.getBoundingClientRect();
  return (Math.atan2(ev.clientY - c.top, ev.clientX - c.left) * 180) / Math.PI;
}

function setAngle(a) {
  slider.style.setProperty('--ring-angle', `${a}deg`);
}

slider.addEventListener('pointerdown', (ev) => {
  // 慣性の途中で掴まれたら、いま見えている角度でその場に止めます
  angle = parseFloat(getComputedStyle(slider).getPropertyValue('--ring-angle')) || 0;
  slider.classList.add('is-dragging');
  setAngle(angle);

  dragging = true;
  slider.setPointerCapture(ev.pointerId);  // 枠の外へ出ても追い続けます
  startPointer = pointerAngle(ev);
  startAngle = angle;
  last = { a: angle, t: ev.timeStamp };
  velocity = 0;
});

slider.addEventListener('pointermove', (ev) => {
  if (!dragging) return;

  let delta = pointerAngle(ev) - startPointer;
  delta = ((delta + 540) % 360) - 180;   // 掴んだ瞬間に一周ぶん飛ばないように
  angle = startAngle + delta;
  setAngle(angle);

  const dt = ev.timeStamp - last.t;
  if (dt > 0) {
    velocity = (angle - last.a) / dt;
    last = { a: angle, t: ev.timeStamp };
  }
});

function release() {
  if (!dragging) return;
  dragging = false;
  slider.classList.remove('is-dragging');

  // 惰性で進む角度を一度だけ書いて、あとは CSS に任せます
  const glide = Math.max(-720, Math.min(720, velocity * 420));
  angle += glide;
  setAngle(angle);
}

slider.addEventListener('pointerup', release);
slider.addEventListener('pointercancel', release);

velocity * 420 の 420 は、勢いをどれだけ引き伸ばすかの係数です。大きくすると、ひと振りで遠くまで滑ります。上下の 720 度は、力いっぱい振ったときに何周も回り続けないための歯止めです。

指を離したあと、このコードは何もしていません

ここが今回いちばん確かめたかったところです。指を離してから止まるまでの間、JavaScript は本当に休んでいるのでしょうか。

実際にブラウザを操作して測りました。指を離した直後から 1.4 秒間、MutationObserver(要素の属性が書き換わったら教えてくれる仕組み)で style と class の変化を数えつつ、14 枚すべての中心からの距離を毎フレーム記録しています。

frames: 84
styleWritesDuringGlide: 0
minDistEver: 420   maxDistEver: 420

84 フレームのあいだ、JavaScript による書き換えは 0 回。それでいて 14 枚すべてが、常に半径 420px の円周上にいました。惰性の動きも、その間に重なっている自動回転も、全部ブラウザ側で進んでいます。

最初の素直な実装で同じことをすると、この距離は 420px から外れっぱなしになります。どれだけ外れるかは振った勢い次第で、そこはさきほどの表のとおりです。ひと振りで半周ぶん回れば、途中でいったん中心まで落ちます。

観覧車のように、常に垂直にしたいとき

ここまでの作りでは、スライドは円に沿って傾きます。扇のように広がる見た目で、これはこれで気持ちがいいのですが、中身が写真や文字だと、傾いていては読めません。

観覧車のゴンドラは、輪がどれだけ回っても座席は水平のままです。同じことをやりましょう。やることは単純で、乗ってきた回転をそのぶん打ち消すだけです。

打ち消すべき角度は、さきほど足し合わせた3つがそのまま該当します。並べるための角度、手で回した角度、自動回転の角度。この合計をマイナスにして、スライドの中身に掛けます。

.arc-slider.is-upright .arc-face {
  rotate: calc(-1 * (var(--spin-angle) + var(--ring-angle) + var(--item-angle)));
}

たったこれだけです。.arc-slider に is-upright を付ければ観覧車型、外せば扇型に切り替わります。

なぜ JavaScript が要らないのでしょうか。 打ち消しに使っている3つの角度が、すべて @property で登録済みか、そもそも動かない値だからです。ブラウザは輪を回すときに角度を補間しており、その同じ値を打ち消し側も読んでいます。輪と中身が、常に同じ瞬間の角度を見ているので、ずれようがありません。

同じ円弧の上に並んだスライドの対比図。左は扇のように「円に沿って傾く」形、右は観覧車のゴンドラのように「常に垂直」を保つ形
図 4: 同じ輪の上で、傾けるか、垂直を保つか

垂直が本当に保たれているかも測りました。最初は「傾くと外接する長方形が大きくなる」性質を使おうとしたのですが、これは失敗でした。120px 四方のスライドが θ 傾いたときの外接長方形の辺は 120·(|cos θ| + |sin θ|) で、θ が 90 度の倍数のときも 120 に戻ってしまいます。 真横に倒れているのか、まっすぐ立っているのか、この数値だけでは区別がつきません。

そこで測り方を変えて、各スライドの隅に小さな目印を置き、目印から中心へ向かう向きを測りました。まっすぐなら 45 度、90 度倒れれば 135 度になるので、取り違えようがありません。

そのうえで、輪を 640 度ぶん回すトランジションを一時停止して、進捗を 0% から 100% まで進めながら 14 枚すべてを測りました。

f=0     --ring-angle=17deg       最大の傾き 0 度
f=0.25  --ring-angle=506.513deg  最大の傾き 0 度
f=0.5   --ring-angle=632.285deg  最大の傾き 0 度
f=0.75  --ring-angle=655.012deg  最大の傾き 0 度
f=1     --ring-angle=657deg      最大の傾き 0 度

輪が 640 度も回り、スライドがあらゆる向きを通過するあいだ、傾きはずっと 0 度ちょうどでした。端だけでなく途中も、きっちり垂直です。

この記録には、おまけの情報も写っています。--ring-angle の進み方が 17 度から 506 度まで一気に来て、そこから 657 度へじりじり近づいていますね。さきほど指定したイージングが、まさにこの形をしています。

下のデモで、扇型と観覧車型を切り替えてみてください。 半径・枚数・勢いの伸びをその場で動かせるので、コードのどの数字が何に効いているのかが見えます。初期値は上のコードと同じ 420px / 14 枚 / 420 です。

デモ 2: 扇型と観覧車型を切り替え、半径・枚数・勢いの伸びをその場で変える

落とし穴その1: 角度を丸めてはいけません

角度が増え続けるのは気持ち悪いので、angle % 360 で 0 度から 360 度に収めたくなります。やってはいけません。

350 度から 10 度へ動かした場合と、丸めずに 350 度から 370 度へ動かした場合を、それぞれ測りました。

350deg → 10deg(丸めた場合)
 t=0    350 度の位置
 t=0.25 左へ回っている
 t=0.5  円の真下(180 度)    ← 340 度ぶん逆走
 t=1    10 度の位置

350deg → 370deg(丸めない場合)
 t=0    350 度の位置
 t=0.5  円の真上(360 度)    ← 20 度ぶん進んだだけ
 t=1    370 度の位置

20 度だけ進めたつもりが、340 度ぶん逆向きに回っていきます。ブラウザは数値をそのまま補間するので、350 と 10 を渡されたら 350 から 10 へ減らすのが正しい仕事です。角度が同じ場所を指すことなど、知る由もありません。

この逆走は、文字で読むと信じにくいところです。下のデモで、角度を丸めた場合と丸めない場合をボタンで見比べてください。 どちらも「20 度だけ進める」つもりの操作です。

デモ 3: 350 度から 10 度へ「20 度だけ進める」ときに何が起きるか

ここで少しややこしい話をします。仕様には “Don’t rotate the long way around”、つまり遠回りするなという規定があるのです。これを読むと、いま見た逆走と食い違って見えます。

どちらが正しいのでしょうか。 実は両方正しくて、効く場所が違います。この規定は、冒頭で触れた2つ目の経路、つまり行列に落として分解してから補間するときの前処理です。関数どうしを直接補間する1つ目の経路には効きません。

確かめるために、わざと行列補間へ落ちる書き方を作って測りました。

関数の並びが揃う場合    t=0.5 で円の真下  ← 340 度の逆走
行列補間へ落ちる場合    t=0.5 で円の真上  ← 短いほうの 20 度

規定どおり、行列補間の側は短いほうを通ります。ただし喜ぶのはまだ早くて、このとき記録された位置は中心から 413.6px でした。420px ではありません。行列補間でも、平行移動の成分は線形に埋められるので、結局は弦を通っています。

つまり、どちらの経路を通っても位置は直線で結ばれます。円を円のまま動かす方法は、位置そのものを角度で表しておくこと以外にありません。

円周上で「350度」から「10度」へ少し進めたいだけなのに、補間は数値が減る向きに進むため、矢印が円をほとんど一周する「340度の逆走」になることを示した図
図 5: 角度を丸めると、近道のつもりが遠回りになる

落とし穴その2: 個別プロパティでは公転できません

CSS には transform: rotate(...) とは別に、rotate: という単独のプロパティもあります。短く書けるので、こちらへ置き換えたくなるかもしれません。

/* これと */
transform: rotate(45deg) translateY(-150px);

/* これは、同じでしょうか */
rotate: 45deg;
translate: 0 -150px;

同じではありません。測ると、こうなりました。

transform: rotate(45deg) translateY(-150px)   →  (506.07, 193.93)
rotate: 45deg; translate: 0 -150px;           →  (400, 150)

まったく違う場所にいます。個別プロパティは適用される順番が仕様で決まっていて、translate、rotate、scale、そして transform の順です。順番を書き換えることはできません。そのため「回してから腕を伸ばす」ができず、「腕を伸ばした先で、その場で自転する」にしかなりません。

注意してほしいのは、どちらも中心からの距離は 150px で同じだったことです。半径だけを見ていると、この取り違えには気づけません。地球でたとえるなら、公転させたかったのに自転しただけ、という違いです。

なお、この記事の観覧車型で rotate: を使っているのは、まさに自転させたいからです。中身をその場で傾け直すだけなので、個別プロパティが適材適所になります。

仕上げにやっておくこと

touch-action: none は指定済みですが、これはスマートフォンで横に払ったときにページのスクロールへ持っていかれないようにするためのものです。付け忘れると、指の動きが半分ブラウザに吸われて反応が鈍くなります。

setPointerCapture も入れてあります。これを呼んでおくと、ドラッグの途中でポインタが枠の外へ出ても、イベントが元の要素に届き続けます。勢いよく振ったときに途中でドラッグが切れる、という症状はたいていこれで直ります。

prefers-reduced-motion にも反応させてあります。動きで気分が悪くなる方が一定数いるので、延々と自動回転し続ける画面は配慮が要ります。この記事の書き方だと、自動回転だけを止めて、手で回す操作は残せます。

キーボード操作については、この記事では扱っていません。実用に載せるなら、左右キーで1枚ずつ送る経路と、各スライドへフォーカスできる仕組みは別途必要です。

まとめ

円形スライダーが崩れる原因は、アニメーションの性能でも、ライブラリの相性でもありませんでした。位置を x と y で持っていたこと、ただ1点です。

x と y をブラウザに埋めさせると、必ず直線が引かれます。それが円周上の2点なら、引かれるのは弦です。位置を角度と腕の長さで持ち替えれば、ブラウザが埋めるのは角度になり、軌跡は勝手に円弧になります。

そうすると、副産物として JavaScript の仕事が激減します。この記事のコードで JavaScript が書いているのは、角度を表すカスタムプロパティ1つだけです。慣性も自動回転も、傾きの打ち消しも、全部 CSS の側で進みます。

次回は、同じものを Swiper.js の上で作ります。ライブラリが自前で transform を書き込んでくる場所では、この「角度で持つ」という方針をどこまで貫けるのか。そこが焦点になります。

なお、この記事の実測値はすべて Chrome 151 / Windows 11 で取ったものです。補間の規則そのものは仕様に書かれているので大きな差は出にくいはずですが、他のブラウザでは確認していません。タッチ端末での操作感も、実機では試していません。

3つのデモも同じ Chrome 151 で確かめています。デモが動かない環境でも記事の主張は追えるように、数字はすべて本文の中に書いてあります。

参考にした一次情報

この記事を書いた人

情報企画チーム 松村 晶(まつむら あき)

2024年11月廣瀬製紙株式会社入社。

書店・福祉・飲食業などを経験したのち、システム開発畑に転向。

転職をきっかけに生まれの地である高知市に移住し、現在は社内SEとして、社内のDBシステム開発やDX関連のシステム開発を担当している。

この著者の記事を見る →