🎨

グラデーションのアニメーション

2022/03/01に公開

グラデーションをアニメーションさせる実装のメモです。

グラデーションの動かし方

ここではCanvasで実装していますが、SVGでも同様の実装ができます。

CSSで色を表現する場合、RGBを使うのが一般的ですが、RGBだとグラデーションを綺麗にアニメーションされるのが難しいため、HSL色空間 でHue(色相)をジワジワ変えるアニメーションを実装しました。鮮やかさと明るさはそのままで、色合いがゆっくり変わるイメージです。コードの中の、 hsl() という色の指定に馴染みがないかもしれませんが、モダンブラウザはもちろん、IE9でもサポートされているため、安心して使えます。

Canvasの addColorStop には、CSSの <color> を指定する仕様なので、 hsl() を少しづつズラしながら毎フレーム更新しています。 RGB -> HSL の変換には下記のページが便利です。
https://www.w3schools.com/colors/colors_converter.asp

TypeScript
// CSS - hsl()
// https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl()
type HslColor = {
  h: [number, number] // Range of hue
  s: number
  l: number
}

type Options = {
  canvas: HTMLCanvasElement
  duration?: number
  colorFrom: HslColor
  colorTo: HslColor
}

class GradientAnimation {
  canvas: HTMLCanvasElement
  context: CanvasRenderingContext2D | null
  color: {
    from: HslColor
    to: HslColor
  }
  width: number
  height: number
  previousTime: number
  elapsedTime: number
  duration: number
  isActive: boolean
  resizeHandler: () => void

  constructor(options: Options) {
    this.canvas = options.canvas
    this.context = this.canvas.getContext('2d')
    this.color = {
      from: options.colorFrom,
      to: options.colorTo,
    }
    this.width = 0
    this.height = 0
    this.previousTime = 0
    this.elapsedTime = 0
    this.duration = options.duration || 8000
    this.isActive = false
    this.resizeHandler = this.resize.bind(this)

    this.bindEvents()
    this.resize()
    this.start()
  }

  bindEvents() {
    window.addEventListener('resize', this.resizeHandler)
  }

  unbindEvents() {
    window.removeEventListener('resize', this.resizeHandler)
  }

  resize() {
    this.width = window.innerWidth
    this.height = window.innerHeight
    this.canvas.width = this.width
    this.canvas.height = this.height
  }

  start() {
    this.isActive = true
    this.previousTime = performance.now()

    this.render()
  }

  stop() {
    this.isActive = false
  }

  toCssColor(color: HslColor, progress: number) {
    const { h, s, l } = color
    const hue = Math.floor(h[0] + (h[1] - h[0]) * progress)

    return `hsl(${hue}, ${s}%, ${l}%)`
  }

  render() {
    if (!this.context) {
      return
    }

    const now = performance.now()
    const delta = now - this.previousTime

    this.elapsedTime += delta
    this.previousTime = now

    // progress: 0 -> 1 -> 0 -> 1 ...
    const rawProgress = (this.elapsedTime % this.duration) / this.duration
    const isForward = Math.floor(this.elapsedTime / this.duration) % 2 === 0
    const progress = isForward ? rawProgress : 1 - rawProgress

    // Top right to bottom left
    const gradient = this.context.createLinearGradient(this.width, 0, 0, this.height)

    gradient.addColorStop(0, this.toCssColor(this.color.from, progress))
    gradient.addColorStop(1, this.toCssColor(this.color.to, progress))

    this.context.fillStyle = gradient
    this.context.fillRect(0, 0, this.width, this.height)

    requestAnimationFrame(() => {
      if (this.isActive) {
        this.render()
      }
    })
  }
}

document.addEventListener('DOMContentLoaded', () => {
  const canvas = document.querySelector<HTMLCanvasElement>('.canvas')

  if (!canvas) {
    return
  }

  new GradientAnimation({
    canvas,
    duration: 6000,
    colorFrom: {
      h: [280, 20], // Range of hue
      s: 75,
      l: 80
    },
    colorTo: {
      h: [120, 350],
      s: 80,
      l: 70
    }
  })
})
JavaScript
class GradientAnimation {
  constructor(options) {
    this.canvas = options.canvas
    this.context = this.canvas.getContext('2d')
    this.color = {
      from: options.colorFrom,
      to: options.colorTo,
    }
    this.width = 0
    this.height = 0
    this.previousTime = performance.now()
    this.elapsedTime = 0
    this.duration = options.duration || 8000
    this.isActive = false
    this.resizeHandler = this.resize.bind(this)

    this.bindEvents()
    this.resize()
    this.start()
  }

  bindEvents() {
    window.addEventListener('resize', this.resizeHandler)
  }

  unbindEvents() {
    window.removeEventListener('resize', this.resizeHandler)
  }

  resize() {
    this.width = window.innerWidth
    this.height = window.innerHeight
    this.canvas.width = this.width
    this.canvas.height = this.height
  }

  start() {
    this.isActive = true
    this.previousTime = performance.now()

    this.render()
  }

  stop() {
    this.isActive = false
  }

  toCssColor(color, progress) {
    const { h, s, l } = color
    const hue = Math.floor(h[0] + (h[1] - h[0]) * progress)

    return `hsl(${hue}, ${s}%, ${l}%)`
  }

  render() {
    if (!this.context) {
      return
    }

    const now = performance.now()
    const delta = now - this.previousTime

    this.elapsedTime += delta
    this.previousTime = now

    // progress: 0 -> 1 -> 0 -> 1 ...
    const rawProgress = (this.elapsedTime % this.duration) / this.duration
    const isForward = Math.floor(this.elapsedTime / this.duration) % 2 === 0
    const progress = isForward ? rawProgress : 1 - rawProgress

    // Top right to bottom left
    const gradient = this.context.createLinearGradient(this.width, 0, 0, this.height)

    gradient.addColorStop(0, this.toCssColor(this.color.from, progress))
    gradient.addColorStop(1, this.toCssColor(this.color.to, progress))

    this.context.fillStyle = gradient
    this.context.fillRect(0, 0, this.width, this.height)

    requestAnimationFrame(() => {
      if (this.isActive) {
        this.render()
      }
    })
  }
}

document.addEventListener('DOMContentLoaded', () => {
  const canvas = document.querySelector('.canvas')

  if (!canvas) {
    return
  }

  new GradientAnimation({
    canvas,
    duration: 6000,
    colorFrom: {
      h: [280, 20], // Range of hue
      s: 75,
      l: 80
    },
    colorTo: {
      h: [120, 350],
      s: 80,
      l: 70
    }
  })
})

シンプルな実装

先人のCodePenですが、CSSで複数のグラデーションを切り替えたり、 background-position をズラしたり、 filterhue-rotate をアニメーションさせる実装がありました。計測はしてないですが、 filterは重そうな気がします。CSSで完結した方がシンプルに実装できますが、JSに比べると表現の幅が狭まるので、一長一短な感じです。

Discussion