Last active
March 16, 2025 18:57
-
-
Save AnoRebel/e58e716c8deda15e23b0d0a83d014343 to your computer and use it in GitHub Desktop.
Closest to my desired water ripple effect by github.com/youyouzh/WaterRipple
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 | |
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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