Liquid Glass
June 13, 2025 | A playful implementation of liquid glass animation (though much simpler than Apple's effect)
I created this liquid glass animation purely for fun, using vanilla JavaScript without any frameworks or WebGL. The code isn't optimized — feel free to experiment with it below.
File Structure
The project consists of just three files (all in the same directory):
plaintext|
Copy
index.html
Vector.js
Glass.jsCodes
You can copy the code directly into local files to try it out. I've added some comments to help explain the logic, but please note the code remains somewhat messy since this was just a quick experiment. It might take some time to fully understand how it works.
index.html
index.htmlhtml|
Copy
<!DOCTYPE html>
<html>
<head>
<script src="./Glass.js" type="module"></script>
</head>
<body style="width: 100%;height: 100%;">
<div style="position: relative; width: 100%; height: 100%;">
<canvas id="canvas"></canvas>
<canvas id="glass" width="100px" height="100px" style="position: absolute; left: 0; top: 0; width: 100px; height: 100px;"></canvas>
</div>
</body>
</html>Vector.js
Vector.jsjavascript|
Copy
// This two classes are written by LLM.
export class Vector2 {
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
static create(x, y) {
return new Vector2(x, y);
}
static zero() {
return new Vector2(0, 0);
}
static one() {
return new Vector2(1, 1);
}
add(v) {
return new Vector2(this.x + v.x, this.y + v.y);
}
subtract(v) {
return new Vector2(this.x - v.x, this.y - v.y);
}
scale(scalar) {
return new Vector2(this.x * scalar, this.y * scalar);
}
dot(v) {
return this.x * v.x + this.y * v.y;
}
cross(v) {
return this.x * v.y - this.y * v.x;
}
magnitude() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
magnitudeSquared() {
return this.x * this.x + this.y * this.y;
}
normalize() {
const mag = this.magnitude();
if (mag === 0) throw new Error('Cannot normalize zero vector');
return new Vector2(this.x / mag, this.y / mag);
}
angleTo(v) {
const dot = this.dot(v);
const magProduct = this.magnitude() * v.magnitude();
if (magProduct === 0) throw new Error('Cannot compute angle with zero vector');
return Math.acos(Math.min(1, Math.max(-1, dot / magProduct)));
}
distanceTo(v) {
const dx = this.x - v.x;
const dy = this.y - v.y;
return Math.sqrt(dx * dx + dy * dy);
}
distanceSquaredTo(v) {
const dx = this.x - v.x;
const dy = this.y - v.y;
return dx * dx + dy * dy;
}
rotate(angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return new Vector2(
this.x * cos - this.y * sin,
this.x * sin + this.y * cos
);
}
lerp(v, t) {
return new Vector2(
this.x + (v.x - this.x) * t,
this.y + (v.y - this.y) * t
);
}
equals(v, epsilon = 0) {
return Math.abs(this.x - v.x) <= epsilon &&
Math.abs(this.y - v.y) <= epsilon;
}
clone() {
return new Vector2(this.x, this.y);
}
toArray() {
return [this.x, this.y];
}
toString() {
return `Vector2(${this.x}, ${this.y})`;
}
}
export class Vector3 {
constructor(x = 0, y = 0, z = 0) {
this.x = x;
this.y = y;
this.z = z;
}
static create(x, y, z) {
return new Vector3(x, y, z);
}
static zero() {
return new Vector3(0, 0, 0);
}
static one() {
return new Vector3(1, 1, 1);
}
add(v) {
return new Vector3(this.x + v.x, this.y + v.y, this.z + v.z);
}
subtract(v) {
return new Vector3(this.x - v.x, this.y - v.y, this.z - v.z);
}
scale(scalar) {
return new Vector3(this.x * scalar, this.y * scalar, this.z * scalar);
}
dot(v) {
return this.x * v.x + this.y * v.y + this.z * v.z;
}
cross(v) {
return new Vector3(
this.y * v.z - this.z * v.y,
this.z * v.x - this.x * v.z,
this.x * v.y - this.y * v.x
);
}
magnitude() {
return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
}
magnitudeSquared() {
return this.x * this.x + this.y * this.y + this.z * this.z;
}
normalize() {
const mag = this.magnitude();
if (mag === 0) throw new Error('Cannot normalize zero vector');
return new Vector3(this.x / mag, this.y / mag, this.z / mag);
}
angleTo(v) {
const dot = this.dot(v);
const magProduct = this.magnitude() * v.magnitude();
if (magProduct === 0) throw new Error('Cannot compute angle with zero vector');
return Math.acos(Math.min(1, Math.max(-1, dot / magProduct)));
}
distanceTo(v) {
const dx = this.x - v.x;
const dy = this.y - v.y;
const dz = this.z - v.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
distanceSquaredTo(v) {
const dx = this.x - v.x;
const dy = this.y - v.y;
const dz = this.z - v.z;
return dx * dx + dy * dy + dz * dz;
}
lerp(v, t) {
return new Vector3(
this.x + (v.x - this.x) * t,
this.y + (v.y - this.y) * t,
this.z + (v.z - this.z) * t
);
}
equals(v, epsilon = 0) {
return Math.abs(this.x - v.x) <= epsilon &&
Math.abs(this.y - v.y) <= epsilon &&
Math.abs(this.z - v.z) <= epsilon;
}
clone() {
return new Vector3(this.x, this.y, this.z);
}
toArray() {
return [this.x, this.y, this.z];
}
toString() {
return `Vector3(${this.x}, ${this.y}, ${this.z})`;
}
}Glass.js
Glass.jsjavascript|
Copy
import { Vector3 } from "./Vector.js";
/**
* Calculate refraction direction
* @param {number} n1 - Refractive index of the incident medium
* @param {number} n2 - Refractive index of the refracting medium
* @param {Vector3} incidentDir - Direction of the incident ray (propagation direction)
* @param {Vector3} normal - Normal at the interface (pointing from the incident medium to the refracting medium)
* @returns {Vector3|null} - Direction of the refracted ray, returns null if total internal reflection occurs
*/
function calculateRefraction(n1, n2, incidentDir, normal) {
// Normalize all vectors
const I = incidentDir.clone().normalize();
const N = normal.clone().normalize();
// Ensure the normal points into the correct hemisphere relative to the incident direction
const cosi = I.dot(N);
if (cosi > 0) {
N.scale(-1);
}
const eta = n1 / n2;
const absCosi = Math.abs(cosi);
const sin2t = eta * eta * (1.0 - absCosi * absCosi);
if (sin2t > 1.0) return null;
const k = Math.sign(cosi) * (eta * absCosi - Math.sqrt(1.0 - sin2t));
return I.scale(eta).add(N.scale(k)).normalize();
}
/**
* Handle refraction from air into a material
* @param {number} materialIOR - Refractive index of the material
* @param {Vector3} incidentDir - Direction of the incident ray (propagation direction)
* @param {Vector3} normal - Normal at the interface (pointing from air to the material)
* @returns {Vector3|null} - Direction of the refracted ray, returns null if total internal reflection occurs
*/
function airToMaterial(materialIOR, incidentDir, normal) {
return calculateRefraction(1.0, materialIOR, incidentDir, normal);
}
/**
* This function is used to define the surface shape of glass.
* @param {number} x - Normalized value, range [-1, 1]
* @param {number} y - Normalized value, range [-1, 1]
* @returns {number[]} - [bottom surface height, top surface height, normal x, normal y, normal z], (x, y, z) is the normal line's direction.
*/
function defineGlass(x, y) {
// ====== Adjustable Parameters ======
const midHeight = 0.1; // Middle height
const glassRadius = 1.0; // Maximum radius of the glass
// I tried different curve shapes to find the best effect
/* const a = 1.014306;
const b = 2.24165e-9;
const c = -23.07102; */
/* const a = 1.014306;
const b = 6.144129000000001e-29;
const c = -69.21305; */
const a = 0.999753;
const b = 2.163425e-9;
const c = -23.09065;
/* const a = 0.9868176;
const b = 2.874165e-14;
const c = -34.71221; */
// =====================
const r = Math.sqrt(x * x + y * y);
if (r <= 1e-4) {
return [0, midHeight, 0, 0, -1]; // Center point handling
}
if (r >= glassRadius) {
return [0, 0, 0, 0, -1]; // Handling outside the boundary
}
const t = r / glassRadius; // Normalized radius [0, 1]
/*
* Here I use a formula (latex) to simulate the surface shape of glass
* y = Y_{0} - \frac{V_{0}}{K}(1 - e^{-Kx})
*/
const baseHeight = a - (b / c) * (1 - Math.exp(-c * t));
// Correct height mapping (center: midHeight, edge: 0)
const height = midHeight * baseHeight;
// Calculate derivative (chain rule)
const dBaseHeight_dt = -b * Math.exp(-c * t);
const dHeight_dt = dBaseHeight_dt * midHeight; // Adjust sign and denominator
const dHeight_dr = dHeight_dt / glassRadius;
// Convert to x/y partial derivatives
const dhdx = dHeight_dr * x / r;
const dhdy = dHeight_dr * y / r;
// Calculate normal vector
const nx = dhdx;
const ny = dhdy;
const nz = 1;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
return [
0, // Bottom surface height
height, // Top surface height
nx / len, // Normal x
ny / len, // Normal y
nz / len // Normal z
];
}
let ctx, glassCtx;
let isDragging = false;
let dragOffsetX, dragOffsetY;
document.addEventListener("DOMContentLoaded", function () {
const canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
glassCtx = document.getElementById('glass').getContext('2d');
const img = new Image();
img.onload = function () {
canvas.setAttribute("width", img.width);
canvas.setAttribute("height", img.height);
ctx.drawImage(img, 0, 0);
};
img.src = '/background.png';
img.onerror = function () {
console.error('Fail to load image.');
};
glass.addEventListener('mousedown', (e) => {
isDragging = true;
const rect = glass.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
glass.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
const containerRect = canvas.getBoundingClientRect();
let newX = e.clientX - containerRect.left - dragOffsetX;
let newY = e.clientY - containerRect.top - dragOffsetY;
newX = Math.max(0, Math.min(containerRect.width - glass.offsetWidth, newX));
newY = Math.max(0, Math.min(containerRect.height - glass.offsetHeight, newY));
glass.style.left = `${newX}px`;
glass.style.top = `${newY}px`;
updateGlass();
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
glass.style.cursor = 'move';
});
updateGlass();
});
/*
* Bilinear interpolation function
* This function determines the color to render in the glass canvas.
* Imagine a light perpendicular to the webpage entering the glass. After refraction, it intersects with a point on the image below. Based on the intersection point (usually a fractional coordinate), I blend the color values of the surrounding four points according to their weights to determine the color that should be rendered for the corresponding point on the glass.
*/
function getColorAtPosition(offsetX, offsetY, x, y) {
const x_ = x / 50 - 1;
const y_ = y / 50 - 1;
const [bottom, top, nx, ny, nz] = defineGlass(x_, y_);
if (top === 0) return { r: 0, g: 0, b: 0, a: 0 };
const normalVec = new Vector3(nx, ny, nz);
const inPoint = new Vector3(offsetX + x, offsetY + y, top + 20);
const innerLight = airToMaterial(1.5, new Vector3(0, 0, -1), normalVec);
const k = -inPoint.z / innerLight.z;
const dx = inPoint.x + k * innerLight.x;
const dy = inPoint.y + k * innerLight.y;
if (isNaN(dx) || isNaN(dy)) {
return [0, 0, 0, 0];
}
const x1 = Math.floor(dx);
const y1 = Math.floor(dy);
const x2 = x1 + 1;
const y2 = y1 + 1;
const canvasWidth = ctx.canvas.width;
const canvasHeight = ctx.canvas.height;
function getPixelSafe(x, y) {
if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight) {
return [0, 0, 0, 0];
}
return ctx.getImageData(x, y, 1, 1).data;
}
const pixel1 = getPixelSafe(x1, y1);
const pixel2 = getPixelSafe(x2, y1);
const pixel3 = getPixelSafe(x1, y2);
const pixel4 = getPixelSafe(x2, y2);
const wx = dx - x1;
const wy = dy - y1;
const w1 = (1 - wx) * (1 - wy);
const w2 = wx * (1 - wy);
const w3 = (1 - wx) * wy;
const w4 = wx * wy;
const r = Math.round(
pixel1[0] * w1 + pixel2[0] * w2 + pixel3[0] * w3 + pixel4[0] * w4
);
const g = Math.round(
pixel1[1] * w1 + pixel2[1] * w2 + pixel3[1] * w3 + pixel4[1] * w4
);
const b = Math.round(
pixel1[2] * w1 + pixel2[2] * w2 + pixel3[2] * w3 + pixel4[2] * w4
);
return { r, g, b, a: 255 };
}
function updateGlass() {
glassCtx.clearRect(0, 0, glass.width, glass.height);
const glassRect = glass.getBoundingClientRect();
const containerRect = canvas.getBoundingClientRect();
const offsetX = glassRect.left - containerRect.left;
const offsetY = glassRect.top - containerRect.top;
const imageData = glassCtx.createImageData(glass.width, glass.height);
const data = imageData.data;
for (let y = 0; y < glass.height; y++) {
for (let x = 0; x < glass.width; x++) {
const color = getColorAtPosition(offsetX, offsetY, x, y);
const pos = (y * glass.width + x) * 4;
data[pos] = color.r;
data[pos + 1] = color.g;
data[pos + 2] = color.b;
data[pos + 3] = color.a;
}
}
glassCtx.putImageData(imageData, 0, 0);
}