円を描くように要素を動かす(HTML/CSS/JS)

前書き

円を描くように要素を動かす方法を掲載しています。

目次

  1. JavaScriptで動かす(平面)
  2. CSSで動かす(平面)
  3. SVGで動かす(平面)
  4. Canvasで動かす(平面)
  5. Canvasで動かす(立体)
  6. 参考

JavaScriptで動かす(平面)

下記の方法で動かしています。

  • CSSで要素を中央に寄せる
  • 三角関数を使って要素の座標を決める
  • 要素のstyle属性を変更する
  • 一定時間毎に繰り返す

円座標を親要素の大きさに合わせて調整しているので、親要素が正方形であれば真円、長方形であれば楕円を描くように動きます。
また、Intersection Observer APIを使って、見えている時だけ動くようにしています。


<div class="wrap js-wrap">
  <div class="item item-01 js-item" data-opt="0.3, 0, 0.25">
    <div class="interior interior-01">い</div>
  </div>
  <div class="item item-02 js-item" data-opt="0.6, 90, 0.35">
    <div class="interior interior-02">ろ</div>
  </div>
  <div class="item item-03 js-item" data-opt="1.0, 180, -0.5">
    <div class="interior interior-03">は</div>
  </div>
</div>
<!-- data-opt="中心からの距離,初めの角度,一回に進む角度" -->

.wrap{
  position: relative; overflow: hidden;
}
.item{
  position: absolute; top: 50%; left: 50%;
  transition: all .2s; //.2sかけて移動させる
}
.interior{
  transform: translate(-50%, -50%); //中央に寄せる
  //装飾
  color: #fff; background: #845080; border-radius: 50%;
  &-01{
    width: 9em; height: 9em;
  }
  &-02{
    width: 6em; height: 6em;
  }
  &-03{
    width: 3em; height: 3em;
  }

//親要素
const wrap = document.querySelector(".js-wrap");

//要素
const itemNode = document.querySelectorAll('.js-item');
const itemArray = Array.from(itemNode);

//設定
const itemOpt = itemArray.map((item) => {
  const opt  = item.dataset.opt.split(',');
  const dist  = Number(opt[0]); //中心からの距離 0~1
  const first = Number(opt[1]); //初めの角度
  const speed = Number(opt[2]); //一回に進む角度
  return { dist, first, speed };
});

//要素の移動------------------

const moveItem = (count) => {

  itemNode.forEach((item, index) => {

    //要素の座標
    const deg = itemOpt[index].speed * count + itemOpt[index].first; //進む角度 × カウント + 初めの角度
    const rad = deg * Math.PI / 180; // 角度をラジアンに変換
    const x = Math.cos(rad);
    const y = Math.sin(rad);

    //要素のstyle属性(座標 × 親要素の半分サイズ × 中心からの距離)
    const itemTsX = x * wrap.clientWidth / 2 * itemOpt[index].dist + "px";
    const itemTsY = y * wrap.clientHeight / 2 * itemOpt[index].dist + "px";
    item.style.transform = "translateX(" + itemTsX + ") translateY(" + itemTsY + ")";

  });
}

//一定時間毎に処理------------------

let tick;
let count = 0;

const switching = (e) => {
  if( e[0].isIntersecting ){ //見えてる時
    tick = () => {
      moveItem( count );
      requestAnimationFrame( tick );
      count++;
    }
    requestAnimationFrame( tick );
  }else{ //見えてない時
    tick = () => {
      cancelAnimationFrame( tick );
    }
  }
}

//見えているかどうか(Intersection Observer API)------------------
const createObserver = () => {
  let observer;
  const options = { root: null, rootMargin: "0%", threshold: 0 };
  observer = new IntersectionObserver(switching, options); //コールバック関数とオプションを渡す
  observer.observe(wrap); //要素の監視を開始
}
createObserver();

CSSで動かす(平面)

下記ページを参考にしています。

CSS3 のみで円周上を周回運動(circular motion)するアニメーション

当ページでは、下記の方法で動かしています。
また、見えている時だけ動くように、JavaScriptで制御しています。

  • 要素(.item)の左上の角が、表示場所の中心に来るように配置
  • 子要素(.interior)の上側にスペースを空ける(空ければ空ける程、大きく回る)
  • 要素を、要素の左上の角を起点に回転させる。
  • 子要素を、子要素の中心を起点に、要素と同じ速度で逆向きに回転させる。

「ろ」と書いてある部分だけに、点線の枠を付けています。
要素(.item)は点線の枠のある四角です。子要素(.interior)は紫色の円です。
見た目上は、子要素の位置だけが変わっているように見えます。


<div class="wrap js-wrap-b">
  <div class="item item-01">
    <div class="interior interior-01">い</div>
  </div>
  <div class="item item-02">
    <div class="interior interior-02">ろ</div>
  </div>
  <div class="item item-03">
    <div class="interior interior-03">は</div>
  </div>
</div>

.wrap{
  position: relative; overflow: hidden;
}
.item{
  //位置
  position: absolute; top: 50%; left: 50%; width: 50%; height: 50%;
  //アニメーション
  transform-origin: 0 0; //左上の角を起点に回転させる
  animation-timing-function: linear;
  animation-iteration-count: infinite;
  &-01{
    animation-duration: 18s;
  }
  &-02{
    animation-duration: 12s; border: 1px dashed #a675a5;
  }
  &-03{
    animation-duration: 9s; animation-direction: reverse;
  }
}
//見えている時だけ動かす
.active .item{
  animation-name: rotate-item;
}

.interior{
  //位置、装飾
  position: relative; color: #fff; background: #845080; border-radius: 50%;
  //アニメーション
  transform-origin: 0% 0%; //※1
  animation-name: rotate-interior;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
  &-01{
    top: 20%; width: 9em; height: 9em;
    animation-duration: 18s; animation-direction: reverse;
  }
  &-02{
    top: 60%; width: 6em; height: 6em;
    animation-duration: 12s; animation-direction: reverse;
  }
  &-03{
    top: 100%; width: 3em; height: 3em;
    animation-duration: 9s;
  }
}
//見えている時だけ動かす
.active .interior{
  animation-name: rotate-interior;
}

@keyframes rotate-item {
  0%   { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
@keyframes rotate-interior {
  0%   { transform: rotate(0deg) translate(-50%, -50%); } //※2
  100% { transform: rotate(360deg) translate(-50%, -50%); } //※2
}

//※2 中央に寄せる為、translateも記述
//※1 中心を起点に回転させるのに(50%, 50%)でないのは、※2でtranslate(-50%, -50%) を指定している為。
(0%, 0%)が円の中心になる。

//見えているかどうか(Intersection Observer API)------------------

const wrap = document.querySelector(".js-wrap-b"); //親要素

const switching = (e) => {
  if( e[0].isIntersecting ){ //見えてる時
    wrap.classList.add("active");
  }else{ //見えてない時
    wrap.classList.remove("active");
  }
}

const createObserver = () => {
  let observer;
  const options = { root: null, rootMargin: "0%", threshold: 0 };
  observer = new IntersectionObserver(switching, options); //コールバック関数とオプションを渡す
  observer.observe(wrap); //要素の監視を開始
}

createObserver();

SVGで動かす(平面)

SVGのアニメーション要素で動かしています。
パスで楕円を描き、そのパスの上を要素が動くようにしています。
また、見えている時だけ動くように、JavaScriptで制御しています。


<div class="wrap js-wrap-c">
  <svg viewbox="0 0 720 360">

    <!-- 開始、停止に利用する要素 -->
    <rect id="start" x="0" y="0" width="0" height="0" />
    <rect id="stop" x="0" y="0" width="0" height="0" />

    <!-- パス -->
    <path id="path01" class="path" d="M 240 180 A 120  60 0 0 1 480 180 A 120  60 0 0 1 240 180" />
    <path id="path02" class="path" d="M 120 180 A 240 120 0 0 1 600 180 A 240 120 0 0 1 120 180" />
    <path id="path03" class="path" d="M   0 180 A 360 180 0 1 0 720 180 A 360 180 0 1 0   0 180" />

    <!-- 紫色の円、文字、アニメーション -->
    <g>
      <circle class="circle" cx="0" cy="0" r="4.5em" />
      <text class="text" x="0" y="0">い</text>
      <animateMotion dur="18s" repeatCount="indefinite" begin="start.click" end="stop.click">
        <mpath href="#path01" />
      </animateMotion>
    </g>
    <g>
      <circle class="circle" cx="0" cy="0" r="3em" />
      <text class="text" x="0" y="0">ろ</text>
      <animateMotion dur="12s" repeatCount="indefinite" begin="start.click" end="stop.click">
        <mpath href="#path02" />
      </animateMotion>
    </g>
    <g>
      <circle class="circle" cx="0" cy="0" r="1.5em" />
      <text class="text" x="0" y="0">は</text>
      <animateMotion dur="9s" repeatCount="indefinite" begin="start.click" end="stop.click">
        <mpath href="#path03" />
      </animateMotion>
    </g>

  </svg>
</div>

.path{
  stroke: #a675a5; stroke-width: 1px; stroke-dasharray: 2 2; fill: none;
}
.circle{
  fill: #845080; mix-blend-mode: multiply;
}
.text{
  fill: #fff; text-anchor: middle; dominant-baseline: central;
}

//見えているかどうか(Intersection Observer API)------------------

const wrap = document.querySelector(".js-wrap-c"); //親要素
const startNode = document.querySelector('#start'); //開始に利用する要素
const stopNode = document.querySelector('#stop'); //停止に利用する要素

const clickEvent = new CustomEvent('click'); //カスタムイベント

const switching = (e) => {
  if( e[0].isIntersecting ){ //見えてる時
    startNode.dispatchEvent(clickEvent);
  }else{ //見えてない時
    stopNode.dispatchEvent(clickEvent);
  }
}

const createObserver = () => {
  let observer;
  const options = { root: null, rootMargin: "0%", threshold: 0 };
  observer = new IntersectionObserver(switching, options); //コールバック関数とオプションを渡す
  observer.observe(wrap); //要素の監視を開始
}

createObserver();

Canvasで動かす(平面)

Canvasで動かしています。
見えている時だけ動くようにしています。


<div class="wrap js-wrap-d">
  <canvas class="canvas js-canvas"></canvas>
</div>

.wrap{
  position: relative; padding: 50% 0 0;
}
.canvas{
  position: absolute; top: 0; left: 0; width: 100%; height: 100%;
}

//ディスプレイのデバイスピクセル比
const dpr = window.devicePixelRatio;

//親要素
const wrap = document.querySelector(".js-wrap-d");
const wrapStyles = window.getComputedStyle(wrap); //親要素のスタイル
const wrapFont = wrapStyles.getPropertyValue("font-family"); //親要素のフォント

//キャンバス
const canvas = document.querySelector(".js-canvas");
const ctx = canvas.getContext("2d");
const canvasFont = "bold " + dpr + "em " + wrapFont;

//設定
let itemOpt =[
  { text: "い", size: 77, dist: 0.3, first:  20, speed: 0.25, },
  { text: "ろ", size: 48, dist: 0.6, first:   0, speed: 0.35, },
  { text: "は", size: 24, dist: 1.0, first: 180, speed: -0.5, },
  //文字、円の半径、中心からの距離、初めの角度、一回に進む角度
];

//要素の移動------------------

const moveItem = (count) => {

  //キャンバスサイズ
  canvas.width = wrap.clientWidth * dpr;
  canvas.height = wrap.clientHeight * dpr;

  itemOpt.forEach((item) => {

    //要素の座標
    const deg = item.speed * count + item.first; //進む角度 × カウント + 初めの角度
    const rad = deg * Math.PI / 180; // 角度をラジアンに変換
    const x = Math.cos(rad);
    const y = Math.sin(rad);

    //要素の位置(キャンバスの中心 + 座標 x キャンバスの半分 x 中心からの距離)
    const itemX = canvas.width / 2 + x * canvas.width / 2 * item.dist;
    const itemY = canvas.height / 2 + y * canvas.height / 2 * item.dist;

    //円
    ctx.fillStyle = "#845080";
    ctx.globalCompositeOperation = "multiply"; //乗算
    ctx.beginPath();
    ctx.arc(itemX, itemY, item.size * dpr , 0, Math.PI*2);
    ctx.closePath();
    ctx.fill();

    //テキスト
    ctx.font = canvasFont;
    ctx.fillStyle = "#fff";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.globalCompositeOperation = "normal"; //乗算取り消し
    ctx.fillText( item.text, itemX, itemY);

  });
}

//一定時間毎に処理------------------
let tick;
let count = 0;
const switching = (e) => {
  if( e[0].isIntersecting ){ //見えてる時
    tick = () => {
      moveItem( count );
      requestAnimationFrame( tick );
      count++;
    }
    requestAnimationFrame( tick );
  }else{ //見えてない時
    tick = () => {
      cancelAnimationFrame( tick );
    }
  }
}

//見えているかどうか(Intersection Observer API)------------------
const createObserver = () => {
  let observer;
  const options = { root: null, rootMargin: "0%", threshold: 0 };
  observer = new IntersectionObserver(switching, options); //コールバック関数とオプションを渡す
  observer.observe(wrap); //要素の監視を開始
}
createObserver();

Canvasで動かす(立体)

下記ページを参考にthree.jsを使って動かしています。

three.js

Three.jsで入れ子構造
  • 球体を作成して、グループに追加する
  • グループを回転させる

<div class="wrap js-wrap-e">
  <canvas class="canvas js-canvas02"></canvas>
</div>

.wrap{
  position: relative; padding: 50% 0 0; overflow: hidden;
}
.canvas{
  position: absolute; top: 0; left: 0; width: 100%; height: 100%;
}

import * as THREE from './three_r127/build/three.module.js';
import { OrbitControls } from './three_r127/examples/jsm/controls/OrbitControls.js';

// サイズ------------------
const wrap = document.querySelector(".js-wrap-e");
let wrapWidth = wrap.clientWidth;
let wrapHeight = wrap.clientHeight;

// レンダラー------------------
const renderer = new THREE.WebGLRenderer({
  canvas: document.querySelector(".js-canvas02"),
  antialias: true
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(wrapWidth, wrapHeight);

// シーン------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color( 0xf5edf3 ); //背景色
scene.fog = new THREE.Fog(0xf5edf3, 500, 2200); //フォグ

// カメラ------------------
const camera = new THREE.PerspectiveCamera(45, wrapWidth / wrapHeight);
camera.position.set(-100, 150, 500);
camera.lookAt(new THREE.Vector3(0, 0, 0));

// カメラコントローラーを作成
const controls = new OrbitControls( camera, renderer.domElement );

// 光源------------------
const dLight = new THREE.DirectionalLight(0xffffff, 1); // 平行光源
dLight.position.set(1, 1, 1);
scene.add(dLight);

const aLight = new THREE.AmbientLight(0xffffff, 0.5); // 環境光源
scene.add(aLight);

// 物体------------------
//地面
const ground = new THREE.LineSegments(
  new THREE.WireframeGeometry(
    new THREE.PlaneGeometry(4000, 4000, 75, 75),
  ),
  new THREE.LineBasicMaterial({color: 0x845080, transparent:true, opacity:0.25}),
);
ground.rotation.x = Math.PI / -2;
scene.add( ground );

//球体
const ballGroup = new THREE.Group(); //グループ作成
scene.add(ballGroup);

for (let i = 0; i < 10; i++) {

  const ballTexture = new THREE.TextureLoader().load( i + '.svg' ); //画像
  const ball = new THREE.Mesh(
    new THREE.SphereGeometry(30, 30, 30),
    new THREE.MeshPhongMaterial( { map: ballTexture } ),
  );

  // 座標
  const rad = (i / 10) * Math.PI * 2;
  const x = 250 * Math.cos(rad);
  const y = 30;
  const z = 250 * Math.sin(rad);

  //配置
  ball.position.set( x, y, z );
  ball.rotation.y = -rad; // 向き
  ballGroup.add(ball); //グループに追加
}

//リサイズ------------------
const wrapResize = () =>{
  wrapWidth = wrap.clientWidth;
  wrapHeight = wrap.clientHeight;
  renderer.setSize(wrapWidth, wrapHeight);
  // camera.aspect = wrapWidth / wrapHeight;
  // camera.updateProjectionMatrix();
}

//一定時間毎に処理------------------
let tick;
const switching = (e) => {
  if( e[0].isIntersecting ){ //見えてる時
    tick = () => {
      wrapResize(); //リサイズ
      ballGroup.rotation.y += 0.01; //グループを回転
      renderer.render(scene, camera); //レンダリング
      requestAnimationFrame( tick );
    }
    requestAnimationFrame( tick );
  }else{ //見えてない時
    tick = () => {
      cancelAnimationFrame( tick )
    }
  }
}

//見えているかどうか(Intersection Observer API)------------------
const createObserver = () => {
  let observer;
  const options = { root: null, rootMargin: "0%", threshold: 0 };
  observer = new IntersectionObserver(switching, options); //コールバック関数とオプションを渡す
  observer.observe(wrap); //要素の監視を開始
}
createObserver();

参考

簡単なThree.jsのサンプルを試そう

WebGL開発に役立つ重要な三角関数の数式・概念まとめ (Three.js編)

円周上に沿ってアニメーションさせてみる

君は使い分けられるか?CSS/SVG/Canvasのビジュアル表現でできること・できないこと

HTML SVG の基本的な使い方

SVG アニメーション(SMIL を使ったアニメーション)

Intersection Observer API

canvasのレスポンシブ対応

three.jsで地球を作ってみよう!〜基礎の基礎編2〜

関連記事

簡単な地図を自作する(HTML/CSS/JS)

パーティクルを作成する(HTML/JS)

文字を立体で表示する(HTML/CSS/JS)

360度画像を表示する(HTML/JS)

うにょうにょ動かす(HTML/CSS/JS)

マウスの位置に合わせて要素を動かす(HTML/CSS/JS)

先頭に戻る
ページの先頭に戻る