🎨
グラデーションのアニメーション
グラデーションをアニメーションさせる実装のメモです。
グラデーションの動かし方
ここではCanvasで実装していますが、SVGでも同様の実装ができます。
CSSで色を表現する場合、RGBを使うのが一般的ですが、RGBだとグラデーションを綺麗にアニメーションされるのが難しいため、HSL色空間 でHue(色相)をジワジワ変えるアニメーションを実装しました。鮮やかさと明るさはそのままで、色合いがゆっくり変わるイメージです。コードの中の、 hsl() という色の指定に馴染みがないかもしれませんが、モダンブラウザはもちろん、IE9でもサポートされているため、安心して使えます。
Canvasの addColorStop には、CSSの <color> を指定する仕様なので、 hsl() を少しづつズラしながら毎フレーム更新しています。 RGB -> HSL
の変換には下記のページが便利です。
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
をズラしたり、 filter の hue-rotate
をアニメーションさせる実装がありました。計測はしてないですが、 filterは重そうな気がします。CSSで完結した方がシンプルに実装できますが、JSに比べると表現の幅が狭まるので、一長一短な感じです。
Discussion