Help us understand the problem. What is going on with this article?

Vue.js+SVGで自由にCSSアニメーションしたい人のための完全解説(ソース付き)

More than 1 year has passed since last update.

こんにちは。UX&フロントエンジニアしながら絵描きして遊んでいるゆき(@yuneco)です。この記事ではVue.jsを使ってCSSアニメーションを使った表現を自在に行うための基本的な部分をステップバイステップで解説します。目標は↓以下のようなアニメーションをJavaScriptで自在に構築できるようになることです。

0_goal.gif
ソースコードはこちら: https://github.com/yuneco/css-anime-tutorial

目次

この記事では最初にSVGを単純に表示するところから始め、Vueのコンポーネントを利用してそのSVGを自由に配置・変形させる方法を説明します。その上でCSS transitionを用いたアニメーションを取り入れます。最後に、複雑なアニメーションを抽象化・構造化してより複雑なシーンを構成するための方法を解説します。

  1. SVGを作る
  2. Vueプロジェクトを作る
  3. SVGを表示する
  4. 好きな場所に配置する
  5. 大きさ・角度を自由に変える
  6. アニメーションさせる
  7. 連続して動かす(キーフレームアニメーション)
  8. アニメーションの抽象化と構造化

おことわり

  • この記事で解説する方法は必ずしもアニメーションを構築する際のスタンダードな方法ではありません
  • 楽に複雑なアニメーションを作りたい場合、animejsPixiJS等、高度なライブラリの導入・学習を検討してください
  • この記事ではアニメーション専用のライブラリを用いずに自力でアニメーション構築までたどり着くことでVue.jsやJavaScript、CSSアニメーション等に関するより深い理解を得ることを狙っています

...若干言い訳っぽくもありますが、単純に「自分で仕組みを理解してアニメーションを作れる」スキルを身に付けるのは間違いなく楽しいことです。少々長い記事になりますがお付き合いいただければ幸いです :blush:

SVGを作る

まず最初にこのチュートリアルで使うSVGを作ります。Illustratorで好きなキャラクターを作成し、メニューから[ファイル]>[書き出し]>[スクリーン用に書き出し]を選択します。「形式」をSVGに変更し、右側の歯車アイコンから設定を表示します。
image.png

...ちょっと小難しい設定が出てきました:worried:。でも今回は「SVGを使って〜」と言いながらもSVGタグ自体をVue.jsでゴリゴリするわけではないのでここの設定はさして気にしなくて大丈夫です。右下の「レスポンシブ」チェックだけ外しておいてください。1

設定ができたら「設定を保存」>「アートボードを書き出し」でSVGファイルを出力します。Illustratorがない方は別なファイルでももちろんOKです。面倒な場合はひとまずgithubにファイルを置いたので使ってください。

ブラウザにドロップすると、こんな感じで表示されると思います。名前は「タマさん」です。今決めました。(今回はわかりやすくするため、1pxの青線でボーダーを入れています)
image.png

Vueプロジェクトを作る

なにはともあれVueのプロジェクトを作らないと始まりません。vue create プロジェクト名でプロジェクトを作成します。この記事では下記の構成で進めますが、もちろん好みに応じて変えていただいても大丈夫です。2

? Please pick a preset: 
  default (babel, eslint) 
❯ Manually select features 

? Check the features needed for your project: 
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◯ Router
 ◯ Vuex
❯◉ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported 
by default): (Use arrow keys)
❯ Sass/SCSS (with dart-sass) 
  Sass/SCSS (with node-sass) 
  Less 
  Stylus 

? Pick a linter / formatter config: 
  ESLint with error prevention only 
  ESLint + Airbnb config 
❯ ESLint + Standard config 
  ESLint + Prettier 

以後すべてデフォルト

プロジェクトができたら、余計なHelloWorldコンポーネントを削除し、空っぽのプロジェクトがnpm run serveで立ち上がることまで確認してください。

SVGを表示する

先ほど作成したSVG(tama.svg)をプロジェクトの/public/img/に配置します(imgディレクトリは作成してください)。まずはこのSVGをVueで表示するところから始めましょう。

普通にSVGを出すだけであればVueはあんまり関係ないのですが、後々のことを考えてこの時点でVueのコンポーネントにしておきます。componentsディレクトリに新しくファイルを作成し、以下を記述します:

src/components/Tama.vue
<template>
  <img src="/img/tama.svg" alt="タマさん">
</template>

このチュートリアルでは一番簡単な方法...ということでimgタグで読み込みをします。
他にもSVGを表示する方法として、

  1. <svg>タグで直接記述する
  2. cssのbackground-imageで画像として読み込む
  3. vue-svg-loader等を利用してVueのコンポーネントとして読み込む

といった方法があります。特に1はより自由度の高い表現(SVGの中の一部分の色や形をVueで制御する、等)ができるので、より複雑な表現を追求したい方はぜひチャレンジしてみてください。

シンプルですがTamaコンポーネントができたのでこれを表示します。App.vueから読み込んで表示しましょう。

App.vue
<template>
  <div id="app">
    <tama></tama>
  </div>
</template>

<script>
import Tama from './components/Tama.vue'
export default {
  name: 'app',
  components: {
    Tama
  }
}
</script>

<style lang="scss">
html, body {
  margin: 0;
  padding: 0;
}
body {
  position: relative;
  height: 100%;
  background: url('/img/grid.svg') repeat;
}
#app {
  margin: 0;
}
</style>

Tamaコンポーネントをそのまま読み込んで配置しているだけです。
スタイル部分は余計なマージン等をリセットして、わかりやすくするためにbodyの背景にグリッド画像を表示しました。

こんな感じで表示されればまずはクリアです!
image.png

好きな場所に配置する

とりあえず画面に絵はでましたが、好きな場所に自由に表示できないと絵作りができませんよね。次はこのタマさんを思った場所に配置できるようにしていきます。

指定の座標に配置する

ひとまず、x=200px, y=100pxの位置に置いてみましょう。

Tama.vue
<template>
  <img
    class="tama-root"
    src="/img/tama.svg"
    alt="タマさん"
  >
</template>

<style lang="scss" scoped>
.tama-root {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(200px, 100px);
}
</style>

position: absoluteで絶対配置にしてtransformで座標を指定します。位置の指定にはtopleftも使用できますが、CSSでアニメーションをするときにはできる限りtransformを使いましょう。独立した要素の表示位置を指定するだけの用途ではこちらの方が軽く、(いくつか要件もありますが)GPUレンダリングによってよりぬるぬるのアニメーションを実現できます。

できました! (200px, 100px)の位置にタマさんがいらっしゃいました!
image.png

・・・いや、ちょっと待って:upside_down: (200px, 100px)の位置にタマさんを立たせたかったのに、基準が左上になってしまっています。もちろん絵にもよりますが、このタマさんのようなキャラクターであれば足元を基準に配置したいですよね。
image.png

やりかたはいくつかありそうですが、今回は単純にmarginで調整します。

Tama.vue
.tama-root {
  // ...
  margin: -300px auto auto -90px;
}


image.png
これで無事、(200px, 100px)の位置にタマさんがたちました!(見切れてるけど!)

配置する座標をパラメータで制御できるようにする

ひとまず決め打ちの場所に配置することはできましたが、実際に使うときはこの座標を動的に変更したりアニメーションさせたりしたいですよね?よってCSSの中にハードコードするわけにはいきません。Vueのプロパティを使ってこの座標を制御できるようにしましょう。

Tama.vue
<script>
export default {
  name: 'Tama',
  props: {
    x: { type: Number, default: 200 },
    y: { type: Number, default: 100 }
  }
}
</script>

Tama.vueにscriptを追加し、x,y2つのプロパティを作成します。typedefaultも設定しておきましょう。
このプロパティを使って動的にスタイルを変えるので、<style>からtransformを削除し....

Tama.vue
.tama-root {
  // ...
  margin: -300px auto auto -90px;
  // 削除: transform: translate(200px, 100px);
}

代わりにテンプレート内でプロパティを使用してスタイルを指定します:

Tama.vue
<template>
  <img class="tama-root" src="/img/tama.svg" alt="タマさん"
    :style="{
      transform: `translate(${x}px, ${y}px)`
    }"
  >
</template>

これで呼び出し側(App.vue)から好きな位置を指定してタマさんを立たせることができるようになりした。

App.vue
  <div id="app">
    <tama :x="300" :y="400"></tama>
  </div>

大きさ・角度を変える

同じようにして、大きさ(スケール)と角度も変えられるようにしましょう。位置・大きさ・角度を自由にコントロールできるようになれば、複数のパーツを組み合わせて好きな画面を作れるようになりますね。

プロパティで大きさ・角度を制御する

まずは位置と同様にプロパティの追加からです。スケールは縦と横を別々に設定できるように、scaleXscaleYの2つのプロパティにしておきます。

Tama.vue
  props: {
    x: { type: Number, default: 200 },
    y: { type: Number, default: 100 },
    scaleX: { type: Number, default: 1.0 },
    scaleY: { type: Number, default: 1.0 },
    rotate: { type: Number, default: 0 }
  }

このプロパティをテンプレートに反映させます。スケールと回転もtransformで指定できるので簡単ですね。scaleは単位不要ですが、rotateには角度の単位が必要なので忘れずに。

Tama.vue
  <img class="tama-root" src="/img/tama.svg" alt="タマさん"
    :style="{
      transform: `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY}) rotate(${rotate}deg)`
    }"
  >

最後にこれも呼び出し側からプロパティを設定しましょう。

App.vue
  <div id="app">
    <tama :x="300" :y="400" :scale-x="1.5" :scale-y="1.5" :rotate="45"></tama>
  </div>

位置の指定と同じなので簡単ですね・・・あれ?
image.png

確かに大きさと角度は変わりましたが、またしても基準がちょっと変です。このモヤモヤが伝わりますでしょうか...
image.png

この問題を解決するには、専用のCSSプロパティtransform-originを使用します。

Tama.vue
.tama-root {
  // ...
  transform-origin: 90px 100%;
}

transform-originは%でもpxでも設定できるので、いい感じにタマさんの足元を基準に設定してあげましょう。

image.png

アニメーションさせる

やっとかよ...と言われてしまいそうですが、ようやくアニメーションする準備が整いました。まずはシンプルに「クリックすると50px上にジャンプする」アニメーションを作ります。基本的な方針は「クリックされたらタマさんのyプロパティを-50する」だけです。

クリックで位置を変更する

アニメーションの実装場所はTama.vue自身の中と呼び出し側のApp.vueの2箇所が考えられますが、「タマさん自身にジャンプする機能を持たせる」方が自然なのでTama.vueの中に書いていきましょう。

Tama.vue
<template>
  <img class="tama-root" src="/img/tama.svg" alt="タマさん"
    :style="{...}"
    @click="jump(50)"
  >
</template>
...
<script>
export default {
  name: 'Tama',
  props: {...},
  methods: {
    jump (height) {
      this.y -= 50
    }
  }
}
</script>

@clickでクリック時にjumpメソッドを呼び出し、その中でyプロパティを-50しています。この状態でタマさんをクリックすると、クリックのたびにタマさんが50pxづつ上に上がっていきます。

ただ困ったことに、この状態でブラウザのコンソールを見てみると警告がでてしまっています。
image.png

yプロパティは外部(App.vue)で指定できるように公開しているのに、Tama.vueの内部で変えちゃダメだよ!って警告ですね。(x, y)はあくまでベースの立ち位置として使い、内部用の相対位置を管理する変数を追加します。

まず、内部用の変数のためにdataを作成し、変数dxdyを追加します。jumpメソッドで変更する対象もdyに変えましょう。

Tama.vue
<script>
export default {
  name: 'Tama',
  props: {...},
  data () {
    return {
      dx: 0,
      dy: 0
    }
  },
  methods: {
    jump (height) {
      this.dy -= height
    }
  }
}
</script>

テンプレート側はベースの立ち位置(x, y) + 相対位置(dx, dy)で座標を指定します:

Tama.vue
<template>
  <img class="tama-root" src="/img/tama.svg" alt="タマさん"
    :style="{
      transform: `translate(${x + dx}px, ${y + dy}px) ...`
    }"
    @click="jump(50)"
  >
</template>

位置の変更をアニメーションにする

クリックで動くようにはなりましたが、まだアニメーションじゃないですね、これ。アニメーションを追加しましょう。アニメーションの方法としては大きく次の2つが考えられます:

  1. ydyといった座標を制御する変数をタイマー等で連続的に変化させる
  2. 変数の値は一気に変えてしまって、CSSのアニメーションで補間する

1は自由度が高く複雑なアニメーションの表現や制御ができますが、毎フレーム座標の計算が走るため重くなりがちです。2であれば、変更後の値さえ指定してしまえば、間のフレームはブラウザがいい感じに描画してくれるため、基本的に高速です。

今回はもちろん、2で進めましょう。3
実装はCSSに2行足すだけです。

Tama.vue
.tama-root {
  // ...
  transition: transform 1s ease;
  will-change: transform;
}

will-changeはアニメーションを滑らかにするためのおまじないです。(本来は「おまじない」程度の理解で使うのは良くないプロパティなので、詳しくは別な記事:will-changeで目指す60fpsのぬるぬるCSSアニメーションをご参照ください)

これでクリックするたびにヌルっとした動きでタマさんが上にせり上がるようになりました。ひとまず、最初のアニメーション完成です:tada:
1_move1.gif

アニメーションの時間とイージングを制御する

先ほどの例ではアニメーションの時間(1s = 1秒)やイージング(ease)をハードコードしてしまいましたが、これらの設定は当然アニメーションの内容によって変わってきますよね。これも変更可能にしておきましょう。

datadurationeasing変数を追加し、CSSのtransitionをこの2変数を使って組み立てるよう、テンプレートに追加します。

Tama.vue
<template>
  <img ...
    :style="{
      transform: ... ,
      transition: `transform ${duration}ms ${easing}`
    }"
    @click="jump(50)"
  >
</template>
...
<script>
export default {
  name: 'Tama',
  props: {...},
  data () {
    return {
      ...
      duration: 1000,
      easing: 'ease'
    }
  },
  ...
}
</script>

連続して動かす(キーフレームアニメーション)

ジャンプなので浮き上がったら地面に戻ってこないといけませんね。アニメーションを連続して実行する方法を考えます。

イメージとしてはこんな感じ:

Tama.vue
jump (height) {
  this.dy = -height
  // アニメーションが終わるまで待つ
  this.dy = 0
}

タイマーでアニメーションを連続実行する

アニメーションの終了を待ち合わせるには

  1. 単純にタイマーで待つ
  2. CSS transiotionのtransitionendイベントを拾う

の2つが考えられます。

正攻法は2なのですが、複数のアニメーションを入れ子にした場合の制御が結構難しいので、今回はシンプルに1でいきたいと思います。

Tama.vue
jump (height) {
  this.dy = -height
  this.easing = 'ease-out'
  window.setTimeout(() => {
    this.dy = 0
    this.easing = 'ease-in'
  }, this.duration)
}

イージングも設定して、いい感じにふわっとジャンプするようにしてみました。クリックするとバッチリ、ジャンプして元の位置に戻ってきますね!
1_jump1.gif

async/awaitでアニメーションを連続実行する

では、この調子でもっと複雑なアニメーションを...
・・・うん、つらい :sob:

JavaScriptそれなりに書かれている方なら、もう先ほどのコードの時点でヤバみを感じていただいていることと思います。そうです、:imp: コールバックヘル :imp:です。この先3つ4つのアニメーションをこの方式で入れ子にしていくのは地獄でしかありません。

この地獄から抜け出すために、async/awaitを使います。といっても特別なものではなく、単に標準のタイマーをPromise化するだけです。

src/core/Time.js
export default {
  /**
  * Promiseを使い指定の時間待機します。
  * @param {Number} ms 待機時間(ミリ秒)
  * @return {Promise} 引数の指定時間経過後にresolveされます
  */
  wait (ms) {
    return new Promise(resolve => {
      window.setTimeout(resolve, ms)
    })
  }
}

このあたりは馴染みがないとちょっとわかりづらいと思いますが、このPromise化したタイマーを使うとこんな感じで待ち時間を簡単に挟むことができるようになります:

console.log('このログはすぐ表示される')
await Time.wait(2000) // ここで2秒待つ
console.log('このログは2秒後に表示される')

この記事では Promiseやasync/awaitの説明はしませんが、ひとまず使い方のイメージがつけば大丈夫かと思います。

このTime.jsを使うと、先ほどのつらみコードは以下のように書き直せます:

Tama.vue
<script>
import Time from '@/core/Time'
export default {
...

  async jump (height) {
    this.dy = -height
    this.easing = 'ease-out'
    await Time.wait(this.duration)
    this.dy = 0
    this.easing = 'ease-in'
    await Time.wait(this.duration)
  }

...
}

アニメーションの抽象化と構造化

これで3つ以上のアニメーションでも連続実行できそうな仕組みが整いました。せっかくなので、もう少しだけ抽象化してアニメーション専用のライブラリのような快適な仕組みを整えましょう

Tween風メソッドの実装

先ほどの例では、以下の2ステップをアニメーションのパート(キーフレーム)ごとに繰り返しています:

  1. dataの内の変数をいろいろ変更
  2. Time.waitでCSSのアニメーションが終わるのを待つ

この処理を別メソッドに追い出します。

Tama.vue
methods: {
  async tween (props, duration = 1000) {
    Object.assign(this.$data, props)
    this.$data.duration = duration
    await Time.wait(duration)
  },
  async jump (height) {
    await this.tween({ dy: -height, easing: 'ease-out' }, 1000)
    await this.tween({ dy: 0, easing: 'ease-in' }, 1000)
  }
}

追加したtweenメソッドはObject.assignを使って、引数で渡されたpropthis.$dataの変数を上書きします。その上でTime.waitを使用してdurationミリ秒待機します。
呼び出し側のjumpはこれを使ってとってもシンプルにアニメーションをかけるようになりましたね。

複雑な動きの実装

tweenメソッドの実装で複数のアニメーション(キーフレーム)を容易につなげることができるようになりました。これを使ってジャンプの動きをもっと洗練させてみます。

Tama.vue
async jump (height = 200, duration = 2500) {
  await this.tween({ dScaleY: 0.8, easing: 'ease' }, duration * 0.1)
  await this.tween({ dy: -height, dScaleY: 1.1, easing: 'ease-out' }, duration * 0.35)
  await this.tween({ dy: 0, dScaleY: 1.2, easing: 'ease-in' }, duration * 0.35)
  await this.tween({ dScaleY: 0.7, easing: 'ease' }, duration * 0.1)
  await this.tween({ dScaleY: 1.0, easing: 'ease' }, duration * 0.1)
}

ジャンプの前に「溜め」を作ったり、ジャンプ中に体を縦に引き伸ばしたりすることで一枚絵でもぽよんとした可愛らしい動きを作ることができます :relaxed:
3_jump2.gif

ここまでのまとめとして、コンポーネント全体を一度貼っておきます。
ついでにjumpに加えてwalkメソッドも追加してみました。

Tama.vue
<template>
  <img class="tama-root" src="/img/tama.svg" alt="タマさん"
    :style="{
      transform: `translate(${x + dx}px, ${y + dy}px) scale(${scaleX * dScaleX}, ${scaleY * dScaleY}) rotate(${rotate + dRotate}deg)`,
      transition: `transform ${duration}ms ${easing}`
    }"
    @click="jump(200)"
  >
</template>

<style lang="scss" scoped>
.tama-root {
  position: absolute;
  left: 0;
  top: 0;
  margin: -300px auto auto -90px;
  transform-origin: 90px 100%;
  will-change: transform;
}
</style>

<script>
import Time from '@/core/Time'
export default {
  name: 'Tama',
  props: {
    x: { type: Number, default: 200 },
    y: { type: Number, default: 100 },
    scaleX: { type: Number, default: 1.0 },
    scaleY: { type: Number, default: 1.0 },
    rotate: { type: Number, default: 0 }
  },
  data () {
    return {
      dx: 0,
      dy: 0,
      dScaleX: 1.0,
      dScaleY: 1.0,
      dRotate: 0,
      duration: 1000,
      easing: 'ease'
    }
  },
  methods: {
    async tween (props = {}, duration = 1000) {
      Object.assign(this.$data, props)
      this.$data.duration = duration
      await Time.wait(duration)
    },
    async jump (height = 200, duration = 2500) {
      await this.tween({ dScaleY: 0.8, easing: 'ease' }, duration * 0.1)
      await this.tween({ dy: -height, dScaleY: 1.1, easing: 'ease-out' }, duration * 0.35)
      await this.tween({ dy: 0, dScale: 1.2, easing: 'ease-in' }, duration * 0.35)
      await this.tween({ dScaleY: 0.7, easing: 'ease' }, duration * 0.1)
      await this.tween({ dScaleY: 1.0, easing: 'ease' }, duration * 0.1)
    },
    async walk (step = 100, duration = 500) {
      await this.to({ dRotate: 10, dScaleY: 0.8, easing: 'ease' }, duration * 0.2)
      await this.to({ dx: this.dx + step, dy: -step * 0.2, dRotate: -5, dScaleY: 1.1, easing: 'cubic-bezier(.04,.67,.52,1)' }, duration * 0.7)
      await this.to({ dy: 0, dRotate: 0, dScaleY: 1, easing: 'ease' }, duration * 0.1)
    }
  }
}
</script>

アニメーションを構造化する

ここまでで、複数のキーフレームで構成される複雑なアニメーションをjumpwalkといった抽象化されたメソットで呼び出せるようになりました。最後に、これらのアニメーションを組み合わせてさらに複雑な動きを作ります。

App.vueにボタンを追加します。このボタンクリックでタマさんにjumpwalkを組み合わせた一連のアニメーションを演じてもらいましょう。

App.vue
<template>
  <div id="app">
    <button @click="play">Play</button>
    <tama ref="tama" :x="100" :y="300" :scaleX="0.5" :scaleY="0.5"></tama>
  </div>
</template>

ボタンクリック時のplayメソッドの中身はこんな感じです:

App.vue
async play () {
  const tama = this.$refs.tama
  await tama.jump(100, 1500)
  await tama.walk(100, 1200)
  await tama.walk(60, 600)
  await tama.walk(40, 400)
  await tama.jump(200, 2500)
}

小さくジャンプして→3歩歩いて→最後に大きくジャンプする、という一連の動きをとてもシンプルに表現できているのがわかると思います。今回はタマさん一人だけですが、複数のコンポーネントを組み合わせたり入れ子にしたりすることで、ゲームのような複雑な動きも構造化していくことができるはずです。

まとめ

  • CSS transformの座標をVueコンポーネントのプロパティと連動させることで、画像(SVG)を自由な位置・大きさ・角度で簡単に配置できるよ
  • CSS transitionを使えば位置・大きさ・角度の変更をアニメーションにできるよ
  • async/awaitを使えばまるでTweenライブラリを使っているかのように、複雑なアニメーションを自然に書けるよ

  1. 入れたままでも良いのですが、初期状態でサイズが決まっていた方が扱いやすいのです 

  2. Vue.jsの基本については説明しないので、不明な場合には適時Vueの入門記事等を参照してください。 

  3. 過去に記事にした作例だとVueとSVGを使ってシューティングゲーム『ネコ🐱メザシ🐟アタック🌟』を作ったのでソースと解説が1のタイマーを用いるパターンで、VueとFirebaseの基本機能全部使ってぬるぬる動くポートフォリオサイトを作ったのでソースと解説が2のCSSアニメーションのみによるもの(この記事と同じ)です。 

ics
インタラクションデザイン専門のプロダクション。最先端のウェブテクノロジーを駆使し、オンスクリーンメディアの表現分野で活動しています。最新のウェブ技術を発信するサイト「ICS MEDIA」を運営。
https://ics.media/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした