Skip to content

Instantly share code, notes, and snippets.

@AnoRebel
Last active March 16, 2025 18:57
Show Gist options
  • Save AnoRebel/e58e716c8deda15e23b0d0a83d014343 to your computer and use it in GitHub Desktop.
Save AnoRebel/e58e716c8deda15e23b0d0a83d014343 to your computer and use it in GitHub Desktop.
Closest to my desired water ripple effect by github.com/youyouzh/WaterRipple
// WaterRipple.js
/**
* Water Ripple Effect
* Originally created by youyouzh on 2018/3/11
* Optimized and extended for better performance and background support.
* Enhancements:
* 1. Supports background images, colors, and gradients.
* 2. Controlled wave disturbances instead of random flickering.
* 3. Performance optimizations: Processes only affected pixels.
* 4. Allows triggering ripples on user interaction.
*/
;(function (root, factory) {
if (typeof define === "function" && define.amd) {
// AMD (Asynchronous Module Definition)
define([], factory)
} else if (typeof module === "object" && module.exports) {
// CommonJS (Node.js)
module.exports = factory()
} else {
// Global (Browser)
root.WaterRipple = factory()
}
})(typeof self !== "undefined" ? self : this, function () {
/**
* Class representing a water ripple effect.
*/
class WaterRipple {
/**
* Creates a WaterRipple instance.
* @param {HTMLElement} element - The DOM element to apply the ripple effect to.
* @param {Object} [settings={}] - Configuration options for the ripple effect.
*/
constructor(element, settings = {}) {
// Default settings
const defaults = {
image: "",
bgColor: "",
bgGradient: [],
dropRadius: 3,
width: window.innerWidth,
height: window.innerHeight,
delay: 1,
attenuation: 5,
fadeSpeed: 6,
maxAmplitude: 1024,
sourceAmplitude: 512,
auto: false,
timeout: 300,
timer: null,
interactive: true,
gestures: true,
events: ["mousemove", "click"],
}
this.settings = { ...defaults, ...settings }
const { width, height, dropRadius, fadeSpeed, maxAmplitude, sourceAmplitude } = this.settings
this.element = element
this.width = width
this.height = height
this.timeout = timeout
this.timer = timer
this.dropRadius = dropRadius
this.fadeSpeed = fadeSpeed
this.maxAmplitude = maxAmplitude
this.sourceAmplitude = sourceAmplitude
this.amplitudeSize = width * (height + 2) * 2
this.oldIndex = width
this.newIndex = width * (height + 3)
this.mapIndex = 0
this.rippleMap = new Array(this.amplitudeSize).fill(0)
this.lastMap = new Array(this.amplitudeSize).fill(0)
this.halfWidth = width >> 1
this.halfHeight = height >> 1
this.canvas = document.createElement("canvas")
this.canvas.width = width
this.canvas.height = height
this.element.appendChild(this.canvas)
this.ctx = this.canvas.getContext("2d")
this.texture = null
this.ripple = null
this.animationFrameId = null
if (!this.settings.image.length) {
this.loadBackground()
} else {
this.loadImage()
}
window.addEventListener("resize", this.handleScreenChange.bind(this), { passive: true })
window.addEventListener("scroll", this.handleScreenChange.bind(this), { passive: true })
}
/**
* Loads the background image for the ripple effect.
*/
loadImage() {
const image = new Image()
image.crossOrigin = "anonymous"
image.src = this.settings.image
image.onload = () => {
this.ctx.drawImage(image, 0, 0, this.width, this.height)
this.init()
}
}
/**
* Loads the background color or gradient for the ripple effect.
*/
loadBackground() {
if (this.settings.bgGradient && this.settings.bgGradient.length >= 2) {
const gradient = this.ctx.createLinearGradient(0, 0, this.width, this.height)
gradient.addColorStop(0, this.settings.bgGradient[0])
gradient.addColorStop(1, this.settings.bgGradient[1])
this.ctx.fillStyle = gradient
} else {
this.ctx.fillStyle = this.settings.bgColor || "#000"
}
this.ctx.fillRect(0, 0, this.width, this.height)
this.texture = this.ctx.getImageData(0, 0, this.width, this.height)
this.ripple = this.ctx.getImageData(0, 0, this.width, this.height)
this.init()
}
/**
* Initializes the ripple effect.
*/
init() {
this.texture = this.ctx.getImageData(0, 0, this.width, this.height)
this.ripple = this.ctx.getImageData(0, 0, this.width, this.height)
this.animate()
if (this.settings.interactive) {
this.addInteraction()
}
if (this.settings.auto) {
setInterval(() => {
this.disturb(Math.random() * this.width, Math.random() * this.height)
}, this.settings.delay * 1000)
}
}
/**
* Handles window resize events and updates the canvas.
*/
handleScreenChange() {
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.destroy()
// Recreate the canvas and context
this.canvas = document.createElement("canvas")
this.canvas.width = this.width = window.innerWidth
this.canvas.height = this.height = window.innerHeight
this.element.appendChild(this.canvas)
this.ctx = this.canvas.getContext("2d")
// Reload the background or image
if (!this.settings.image.length) {
this.loadBackground()
} else {
this.loadImage()
}
}, this.timeout) // Trigger the event handler after 100ms of inactivity
}
/**
* Destroys the ripple effect and cleans up resources.
*/
destroy() {
window.removeEventListener("resize", this.handleScreenChange.bind(this))
window.removeEventListener("scroll", this.handleScreenChange.bind(this))
cancelAnimationFrame(this.animationFrameId)
if (this.canvas) {
this.canvas.remove()
this.canvas = null
}
this.ctx = null
}
/**
* Animates the ripple effect.
*/
animate() {
this.animationFrameId = requestAnimationFrame(this.animate.bind(this))
this.renderRipple()
}
/**
* Creates a disturbance in the ripple effect at the specified coordinates.
* @param {number} centerX - The x-coordinate of the disturbance.
* @param {number} centerY - The y-coordinate of the disturbance.
*/
disturb(centerX, centerY) {
centerX = Math.floor(centerX)
centerY = Math.floor(centerY)
for (let y = -this.dropRadius; y <= this.dropRadius; y++) {
for (let x = -this.dropRadius; x <= this.dropRadius; x++) {
const px = centerX + x
const py = centerY + y
if (px >= 0 && px < this.width && py >= 0 && py < this.height) {
const index = this.oldIndex + py * this.width + px
this.rippleMap[index] += this.sourceAmplitude
}
}
}
}
/**
* Renders the ripple effect on the canvas.
*/
renderRipple() {
let i = this.oldIndex
this.oldIndex = this.newIndex
this.newIndex = i
i = 0
this.mapIndex = this.oldIndex
const { width, height, halfWidth, halfHeight, rippleMap, lastMap, ripple, texture, fadeSpeed, maxAmplitude } =
this
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const top = rippleMap[this.mapIndex - width]
const bottom = rippleMap[this.mapIndex + width]
const left = rippleMap[this.mapIndex - 1]
const right = rippleMap[this.mapIndex + 1]
let amplitude = (top + bottom + left + right) >> 1
amplitude -= rippleMap[this.newIndex + i]
amplitude -= amplitude >> fadeSpeed
rippleMap[this.newIndex + i] = amplitude
amplitude = maxAmplitude - amplitude
const oldAmplitude = lastMap[i]
lastMap[i] = amplitude
if (oldAmplitude !== amplitude) {
let deviationX = ((((x - halfWidth) * amplitude) / maxAmplitude) << 0) + halfWidth
let deviationY = ((((y - halfHeight) * amplitude) / maxAmplitude) << 0) + halfHeight
deviationX = Math.max(0, Math.min(width - 1, deviationX))
deviationY = Math.max(0, Math.min(height - 1, deviationY))
const pixelSource = i * 4
const pixelDeviation = (deviationX + deviationY * width) * 4
ripple.data[pixelSource] = texture.data[pixelDeviation]
ripple.data[pixelSource + 1] = texture.data[pixelDeviation + 1]
ripple.data[pixelSource + 2] = texture.data[pixelDeviation + 2]
}
++i
++this.mapIndex
}
}
this.ctx.putImageData(ripple, 0, 0)
}
/**
* Handles touch events to create ripples.
* @param {TouchEvent} event - The touch event.
*/
handleTouch(event) {
for (const touch of event.touches) {
const rect = this.canvas.getBoundingClientRect()
const x = touch.clientX - rect.left
const y = touch.clientY - rect.top
this.disturb(x, y)
this.canvas.dispatchEvent(new CustomEvent("ripple-start", { detail: { x, y } }))
}
}
/**
* Adds interaction events for the ripple effect.
*/
addInteraction() {
let lastMouseX = -100,
lastMouseY = -100
const minDist = 5
let rippleQueued = false
this.settings.events.forEach(event => {
this.canvas.addEventListener(event, e => {
const x = e.offsetX,
y = e.offsetY
const dx = x - lastMouseX,
dy = y - lastMouseY
if (!rippleQueued && dx * dx + dy * dy > minDist * minDist) {
rippleQueued = true
this.canvas.dispatchEvent(new CustomEvent("ripple-start", { detail: { x, y } }))
requestAnimationFrame(() => {
this.disturb(x, y)
lastMouseX = x
lastMouseY = y
rippleQueued = false
})
}
})
})
this.canvas.addEventListener("touchstart", this.handleTouch.bind(this), { passive: true })
this.canvas.addEventListener("touchmove", this.handleTouch.bind(this), { passive: true })
if (this.settings.gestures) {
this.addGestureDetection()
}
}
/**
* Adds gesture detection for touch interactions.
*/
addGestureDetection() {
let startX = 0,
startY = 0
const gestureThreshold = 30
const rippleStrength = this.sourceAmplitude * 1.5
this.canvas.addEventListener("touchstart", e => {
if (e.touches.length === 1) {
startX = e.touches[0].clientX
startY = e.touches[0].clientY
}
})
this.canvas.addEventListener("touchend", e => {
if (e.changedTouches.length === 1) {
const endX = e.changedTouches[0].clientX
const endY = e.changedTouches[0].clientY
const deltaX = endX - startX
const deltaY = endY - startY
if (Math.abs(deltaX) > gestureThreshold || Math.abs(deltaY) > gestureThreshold) {
const direction = deltaX > Math.abs(deltaY) ? (deltaX > 0 ? "right" : "left") : deltaY > 0 ? "down" : "up"
const effectStrength = rippleStrength * (direction === "up" ? 0.8 : direction === "down" ? 1.2 : 1)
this.disturb(endX, endY, effectStrength)
this.canvas.dispatchEvent(new CustomEvent("ripple-start", { detail: { x: deltaX, y: deltaY } }))
}
}
})
}
}
return WaterRipple
})
// WaterRipple.ts
/**
* Water Ripple Effect
* Originally created by youyouzh on 2018/3/11
* Optimized and extended for better performance and background support.
* Enhancements:
* 1. Supports background images, colors, and gradients.
* 2. Controlled wave disturbances instead of random flickering.
* 3. Performance optimizations: Processes only affected pixels.
* 4. Allows triggering ripples on user interaction.
*/
type WaterRippleSettings = {
image?: string
bgColor?: string
bgGradient?: string[]
dropRadius?: number
width?: number
height?: number
delay?: number
attenuation?: number
fadeSpeed?: number
maxAmplitude?: number
sourceAmplitude?: number
auto?: boolean
timer?: NodeJS.Timeout | null
timeout?: number
interactive?: boolean
gestures?: boolean
events?: string[]
}
class WaterRipple {
private element: HTMLElement
private settings: WaterRippleSettings
private width: number
private height: number
private timer: NodeJS.Timeout | null
private timeout: number
private dropRadius: number
private fadeSpeed: number
private maxAmplitude: number
private sourceAmplitude: number
private amplitudeSize: number
private oldIndex: number
private newIndex: number
private mapIndex: number
private rippleMap: number[]
private lastMap: number[]
private halfWidth: number
private halfHeight: number
private canvas: HTMLCanvasElement | null = null
private ctx: CanvasRenderingContext2D | null = null
private texture: ImageData | null = null
private ripple: ImageData | null = null
private animationFrameId: number | null = null
constructor(element: HTMLElement, settings: Partial<WaterRippleSettings> = {}) {
const defaults: WaterRippleSettings = {
image: "",
bgColor: "",
bgGradient: [],
dropRadius: 3,
width: window.innerWidth,
height: window.innerHeight,
delay: 1,
timer: null,
timeout: 300,
attenuation: 5,
fadeSpeed: 6,
maxAmplitude: 1024,
sourceAmplitude: 512,
auto: false,
interactive: true,
gestures: true,
events: ["mousemove", "click"],
}
this.settings = { ...defaults, ...settings }
const { width, height, dropRadius, fadeSpeed, maxAmplitude, sourceAmplitude, timer, timeout } = this.settings
this.element = element
this.width = width!
this.height = height!
this.dropRadius = dropRadius!
this.fadeSpeed = fadeSpeed!
this.maxAmplitude = maxAmplitude!
this.sourceAmplitude = sourceAmplitude!
this.timer = timer
this.timeout = timeout!
this.amplitudeSize = this.width * (this.height + 2) * 2
this.oldIndex = this.width
this.newIndex = this.width * (this.height + 3)
this.mapIndex = 0
this.rippleMap = new Array(this.amplitudeSize).fill(0)
this.lastMap = new Array(this.amplitudeSize).fill(0)
this.halfWidth = this.width >> 1
this.halfHeight = this.height >> 1
this.canvas = document.createElement("canvas")
this.canvas.width = this.width
this.canvas.height = this.height
this.element.appendChild(this.canvas)
this.ctx = this.canvas.getContext("2d")
if (!this.settings.image?.length) {
this.loadBackground()
} else {
this.loadImage()
}
window.addEventListener("resize", this.handleScreenChange.bind(this), { passive: true })
window.addEventListener("scroll", this.handleScreenChange.bind(this), { passive: true })
}
private loadImage(): void {
const image = new Image()
image.crossOrigin = "anonymous"
image.src = this.settings.image!
image.onload = () => {
this.ctx?.drawImage(image, 0, 0, this.width, this.height)
this.init()
}
}
private loadBackground(): void {
if (this.settings.bgGradient && this.settings.bgGradient.length >= 2) {
const gradient = this.ctx!.createLinearGradient(0, 0, this.width, this.height)
gradient.addColorStop(0, this.settings.bgGradient[0])
gradient.addColorStop(1, this.settings.bgGradient[1])
this.ctx!.fillStyle = gradient
} else {
this.ctx!.fillStyle = this.settings.bgColor || "#000"
}
this.ctx!.fillRect(0, 0, this.width, this.height)
this.texture = this.ctx!.getImageData(0, 0, this.width, this.height)
this.ripple = this.ctx!.getImageData(0, 0, this.width, this.height)
this.init()
}
private init(): void {
this.texture = this.ctx!.getImageData(0, 0, this.width, this.height)
this.ripple = this.ctx!.getImageData(0, 0, this.width, this.height)
this.animate()
if (this.settings.interactive) {
this.addInteraction()
}
if (this.settings.auto) {
setInterval(() => {
this.disturb(Math.random() * this.width, Math.random() * this.height)
}, this.settings.delay! * 1000)
}
}
private handleScreenChange(): void {
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.destroy()
this.canvas = document.createElement("canvas")
this.canvas.width = this.width = window.innerWidth
this.canvas.height = this.height = window.innerHeight
this.element.appendChild(this.canvas)
this.ctx = this.canvas.getContext("2d")
if (!this.settings.image?.length) {
this.loadBackground()
} else {
this.loadImage()
}
}, this.timeout) // Trigger the event handler after 100ms of inactivity
}
private destroy(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}
if (this.canvas) {
this.canvas.remove()
this.canvas = null
}
this.ctx = null
}
private animate(): void {
this.animationFrameId = requestAnimationFrame(this.animate.bind(this))
this.renderRipple()
}
private disturb(centerX: number, centerY: number): void {
centerX = Math.floor(centerX)
centerY = Math.floor(centerY)
for (let y = -this.dropRadius; y <= this.dropRadius; y++) {
for (let x = -this.dropRadius; x <= this.dropRadius; x++) {
const px = centerX + x
const py = centerY + y
if (px >= 0 && px < this.width && py >= 0 && py < this.height) {
const index = this.oldIndex + py * this.width + px
this.rippleMap[index] += this.sourceAmplitude
}
}
}
}
private renderRipple(): void {
let i = this.oldIndex
this.oldIndex = this.newIndex
this.newIndex = i
i = 0
this.mapIndex = this.oldIndex
const { width, height, halfWidth, halfHeight, rippleMap, lastMap, ripple, texture, fadeSpeed, maxAmplitude } = this
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const top = rippleMap[this.mapIndex - width]
const bottom = rippleMap[this.mapIndex + width]
const left = rippleMap[this.mapIndex - 1]
const right = rippleMap[this.mapIndex + 1]
let amplitude = (top + bottom + left + right) >> 1
amplitude -= rippleMap[this.newIndex + i]
amplitude -= amplitude >> fadeSpeed!
rippleMap[this.newIndex + i] = amplitude
amplitude = maxAmplitude! - amplitude
const oldAmplitude = lastMap[i]
lastMap[i] = amplitude
if (oldAmplitude !== amplitude) {
let deviationX = ((((x - halfWidth) * amplitude) / maxAmplitude!) << 0) + halfWidth
let deviationY = ((((y - halfHeight) * amplitude) / maxAmplitude!) << 0) + halfHeight
deviationX = Math.max(0, Math.min(width - 1, deviationX))
deviationY = Math.max(0, Math.min(height - 1, deviationY))
const pixelSource = i * 4
const pixelDeviation = (deviationX + deviationY * width) * 4
ripple!.data[pixelSource] = texture!.data[pixelDeviation]
ripple!.data[pixelSource + 1] = texture!.data[pixelDeviation + 1]
ripple!.data[pixelSource + 2] = texture!.data[pixelDeviation + 2]
}
++i
++this.mapIndex
}
}
this.ctx!.putImageData(ripple!, 0, 0)
}
private handleTouch(event: TouchEvent): void {
for (const touch of event.touches) {
const rect = this.canvas!.getBoundingClientRect()
const x = touch.clientX - rect.left
const y = touch.clientY - rect.top
this.disturb(x, y)
this.canvas!.dispatchEvent(new CustomEvent("ripple-start", { detail: { x, y } }))
}
}
private addInteraction(): void {
let lastMouseX = -100,
lastMouseY = -100
const minDist = 5
let rippleQueued = false
this.settings.events!.forEach(event => {
this.canvas!.addEventListener(event, e => {
const x = (e as MouseEvent).offsetX,
y = (e as MouseEvent).offsetY
const dx = x - lastMouseX,
dy = y - lastMouseY
if (!rippleQueued && dx * dx + dy * dy > minDist * minDist) {
rippleQueued = true
this.canvas!.dispatchEvent(new CustomEvent("ripple-start", { detail: { x, y } }))
requestAnimationFrame(() => {
this.disturb(x, y)
lastMouseX = x
lastMouseY = y
rippleQueued = false
})
}
})
})
this.canvas!.addEventListener("touchstart", this.handleTouch.bind(this), { passive: true })
this.canvas!.addEventListener("touchmove", this.handleTouch.bind(this), { passive: true })
if (this.settings.gestures) {
this.addGestureDetection()
}
}
private addGestureDetection(): void {
let startX = 0,
startY = 0
const gestureThreshold = 30
const rippleStrength = this.sourceAmplitude * 1.5
this.canvas!.addEventListener("touchstart", e => {
if (e.touches.length === 1) {
startX = e.touches[0].clientX
startY = e.touches[0].clientY
}
})
this.canvas!.addEventListener("touchend", e => {
if (e.changedTouches.length === 1) {
const endX = e.changedTouches[0].clientX
const endY = e.changedTouches[0].clientY
const deltaX = endX - startX
const deltaY = endY - startY
if (Math.abs(deltaX) > gestureThreshold || Math.abs(deltaY) > gestureThreshold) {
const direction = deltaX > Math.abs(deltaY) ? (deltaX > 0 ? "right" : "left") : deltaY > 0 ? "down" : "up"
const effectStrength = rippleStrength * (direction === "up" ? 0.8 : direction === "down" ? 1.2 : 1)
this.disturb(endX, endY)
this.canvas!.dispatchEvent(new CustomEvent("ripple-start", { detail: { x: deltaX, y: deltaY } }))
}
}
})
}
}
export default WaterRipple
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment