Bubble
A glassy droplet rides your cursor, trailing into a string of blending metaballs that refract the live page beneath them. Move fast to stretch it, stop to let it pool back together.
Demo image
Drag the droplet across the photo, then stop and watch it pool back together.
Install
npx shadcn@latest add @canvas-ui/bubble-reactOr copy the source below into your project.
Code
"use client";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
export type BubbleOptions = {
size?: number;
trail?: number;
follow?: number;
blend?: number;
speed?: number;
refraction?: number;
dispersion?: number;
frost?: number;
shine?: number;
rim?: number;
iridescence?: number;
intensity?: number;
tint?: [number, number, number];
tintStrength?: number;
colorA?: [number, number, number];
colorB?: [number, number, number];
fallbackOpacity?: number;
};
export type BubbleElements = {
source: HTMLCanvasElement;
content: HTMLElement;
output: HTMLCanvasElement;
};
export type BubbleInstance = {
setOptions: (options: BubbleOptions) => void;
resize: () => void;
destroy: () => void;
};
const DEFAULTS: Required<BubbleOptions> = {
size: 30,
trail: 24,
follow: 0.5,
blend: 14,
speed: 2,
refraction: 80,
dispersion: 1,
frost: 0,
shine: 0.25,
rim: 0.5,
iridescence: 1,
intensity: 0.9,
tint: [1, 1, 1],
tintStrength: 0,
colorA: [0.2902, 0.4549, 0.7216],
colorB: [0.4118, 0.4118, 0.4157],
fallbackOpacity: 1,
};
const MAX_TRAIL = 24;
type PaintableCanvas = HTMLCanvasElement & {
onpaint?: (() => void) | null;
requestPaint?: () => void;
};
type ElementImageContext = CanvasRenderingContext2D & {
drawElementImage?: (element: Element, x: number, y: number) => void;
};
const VERT = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
void main () {
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
out vec4 outColor;
uniform sampler2D uContent;
uniform vec2 uResolution;
uniform float uMaxX;
uniform float uDpr;
uniform float uTime;
uniform float uHasContent;
uniform int uCount;
uniform vec2 uTrail[${MAX_TRAIL}];
uniform float uBaseRadius;
uniform float uBlend;
uniform float uRefraction;
uniform float uDispersion;
uniform float uFrost;
uniform float uShine;
uniform float uRim;
uniform float uIridescence;
uniform float uIntensity;
uniform vec3 uTint;
uniform float uTintStrength;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform float uFallbackAlpha;
const float EPS = 1e-4;
const int ITR = 16;
vec3 page (vec2 px, float lod) {
vec2 uv = px / uResolution;
uv.x = clamp(uv.x, 0.0005, uMaxX - 0.0005);
uv.y = clamp(uv.y, 0.0005, 0.9995);
return pow(textureLod(uContent, vec2(uv.x, 1.0 - uv.y), lod).rgb, vec3(2.2));
}
float rnd3D (vec3 p) {
return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453123);
}
float noise3D (vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
float a000 = rnd3D(i);
float a100 = rnd3D(i + vec3(1.0, 0.0, 0.0));
float a010 = rnd3D(i + vec3(0.0, 1.0, 0.0));
float a110 = rnd3D(i + vec3(1.0, 1.0, 0.0));
float a001 = rnd3D(i + vec3(0.0, 0.0, 1.0));
float a101 = rnd3D(i + vec3(1.0, 0.0, 1.0));
float a011 = rnd3D(i + vec3(0.0, 1.0, 1.0));
float a111 = rnd3D(i + vec3(1.0, 1.0, 1.0));
vec3 u = f * f * (3.0 - 2.0 * f);
float k0 = a000;
float k1 = a100 - a000;
float k2 = a010 - a000;
float k3 = a001 - a000;
float k4 = a000 - a100 - a010 + a110;
float k5 = a000 - a010 - a001 + a011;
float k6 = a000 - a100 - a001 + a101;
float k7 = -a000 + a100 + a010 - a110 + a001 - a101 - a011 + a111;
return k0 + k1 * u.x + k2 * u.y + k3 * u.z + k4 * u.x * u.y +
k5 * u.y * u.z + k6 * u.z * u.x + k7 * u.x * u.y * u.z;
}
float smoothMin (float d1, float d2, float k) {
float h = exp(-k * d1) + exp(-k * d2);
return -log(max(h, 1e-12)) / k;
}
float map (vec3 p) {
float radius = uBaseRadius * float(uCount);
float d = 1e5;
for (int i = 0; i < ${MAX_TRAIL}; i++) {
if (i >= uCount) break;
float sphere = length(p - vec3(uTrail[i], 0.0)) -
(radius - uBaseRadius * float(i));
d = smoothMin(d, sphere, uBlend);
}
return d;
}
vec3 generateNormal (vec3 p) {
return normalize(vec3(
map(p + vec3(EPS, 0.0, 0.0)) - map(p + vec3(-EPS, 0.0, 0.0)),
map(p + vec3(0.0, EPS, 0.0)) - map(p + vec3(0.0, -EPS, 0.0)),
map(p + vec3(0.0, 0.0, EPS)) - map(p + vec3(0.0, 0.0, -EPS))));
}
vec3 dropletColor (vec3 normal, vec3 rayDir) {
vec3 reflectDir = reflect(rayDir, normal);
float noisePosTime = noise3D(reflectDir * 2.0 + uTime);
float noiseNegTime = noise3D(reflectDir * 2.0 - uTime);
vec3 color0 = uColorA * noisePosTime;
vec3 color1 = uColorB * noiseNegTime;
return (color0 + color1) * uIntensity;
}
void main () {
vec2 frag = gl_FragCoord.xy;
float minRes = min(uResolution.x, uResolution.y);
vec2 p = (frag * 2.0 - uResolution) / minRes;
vec3 ray = vec3(p, 1.0);
vec3 rayDir = vec3(0.0, 0.0, -1.0);
float dist = 0.0;
for (int i = 0; i < ITR; ++i) {
dist = map(ray);
ray += rayDir * dist;
if (dist < EPS || dist > 8.0) break;
}
float cov = 1.0 - smoothstep(0.0, 3.0 / minRes, dist);
if (!(cov > 0.001)) {
outColor = vec4(0.0);
return;
}
vec3 n = generateNormal(ray);
vec3 glints = pow(max(dropletColor(n, rayDir), 0.0), vec3(7.0));
vec3 L = normalize(vec3(-0.5, 0.7, 0.6));
float spec = pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 60.0);
vec3 color;
float alpha = cov;
if (uHasContent > 0.5) {
float depth = uRefraction * uDpr;
float ca = uDispersion * 0.03;
vec3 rvR = refract(rayDir, n, 1.0 / (1.33 - ca));
vec3 rvG = refract(rayDir, n, 1.0 / 1.33);
vec3 rvB = refract(rayDir, n, 1.0 / (1.33 + ca));
vec2 offR = rvR.xy * (depth / max(abs(rvR.z), 0.35));
vec2 offG = rvG.xy * (depth / max(abs(rvG.z), 0.35));
vec2 offB = rvB.xy * (depth / max(abs(rvB.z), 0.35));
float lod = max(uFrost * 5.0, log2(1.0 + length(offG) * 0.05 / uDpr));
vec3 refr = vec3(
page(frag + offR, lod).r,
page(frag + offG, lod).g,
page(frag + offB, lod).b);
refr *= mix(vec3(1.0), uTint, clamp(uTintStrength, 0.0, 1.0));
float edge = pow(1.0 - clamp(n.z, 0.0, 1.0), 1.5);
refr *= 1.0 - 0.35 * uRim * edge;
color = pow(max(refr, 0.0), vec3(1.0 / 2.2));
color += glints * uIridescence;
color += vec3(spec * uShine * 0.9);
} else {
float edge = pow(1.0 - clamp(n.z, 0.0, 1.0), 1.5);
vec3 filmTint = mix(vec3(0.9), uTint, clamp(uTintStrength, 0.0, 1.0));
float fade = cov * clamp(uFallbackAlpha, 0.0, 1.0);
vec3 light = glints * uIridescence * 0.65 + vec3(spec * uShine * 1.5) +
filmTint * (0.55 * max(uRim, 0.4) * edge + 0.03);
float a = fade * clamp(0.08 + 0.4 * edge, 0.0, 1.0);
outColor = vec4(light * fade, a);
return;
}
outColor = vec4(color * alpha, alpha);
}`;
export function supportsHtmlInCanvas(): boolean {
if (typeof document === "undefined") return false;
const probe = document.createElement("canvas") as PaintableCanvas;
const ctx = probe.getContext("2d") as ElementImageContext | null;
return Boolean(
ctx &&
typeof ctx.drawElementImage === "function" &&
typeof probe.requestPaint === "function",
);
}
export function createBubble(
elements: BubbleElements,
options: BubbleOptions = {},
): BubbleInstance | null {
const config = { ...DEFAULTS, ...options };
const { source, content, output } = elements;
const gl = output.getContext("webgl2", {
alpha: true,
depth: false,
stencil: false,
antialias: false,
premultipliedAlpha: true,
});
if (!gl || gl.isContextLost()) return null;
const sourceCtx = source.getContext("2d") as ElementImageContext | null;
const paintable = source as PaintableCanvas;
const htmlInCanvas = Boolean(
sourceCtx &&
typeof sourceCtx.drawElementImage === "function" &&
typeof paintable.requestPaint === "function",
);
let contentDirty = false;
let wake = () => {};
if (htmlInCanvas) {
paintable.onpaint = () => {
try {
sourceCtx!.reset();
sourceCtx!.drawElementImage!(content, 0, 0);
contentDirty = true;
wake();
} catch {}
};
}
function compile(type: number, text: string): WebGLShader {
const shader = gl!.createShader(type)!;
gl!.shaderSource(shader, text);
gl!.compileShader(shader);
if (!gl!.getShaderParameter(shader, gl!.COMPILE_STATUS)) {
console.error("Bubble shader error:", gl!.getShaderInfoLog(shader));
}
return shader;
}
const vertexShader = compile(gl.VERTEX_SHADER, VERT);
const fragmentShader = compile(gl.FRAGMENT_SHADER, FRAG);
const program = gl.createProgram()!;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
const uniforms: Record<string, WebGLUniformLocation> = {};
const uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < uniformCount; i++) {
const info = gl.getActiveUniform(program, i)!;
uniforms[info.name] = gl.getUniformLocation(program, info.name)!;
}
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const contentTexture = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, contentTexture);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0]),
);
gl.generateMipmap(gl.TEXTURE_2D);
let contentMaxX = 1;
function syncCanvasSize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(output.clientWidth * dpr));
const height = Math.max(1, Math.round(output.clientHeight * dpr));
if (output.width !== width || output.height !== height) {
output.width = width;
output.height = height;
}
contentMaxX = Math.min(
1,
Math.max(0.05, content.clientWidth / Math.max(output.clientWidth, 1)),
);
if (htmlInCanvas) {
const sourceWidth = Math.max(1, Math.round(source.clientWidth * dpr));
const sourceHeight = Math.max(1, Math.round(source.clientHeight * dpr));
if (source.width !== sourceWidth || source.height !== sourceHeight) {
source.width = sourceWidth;
source.height = sourceHeight;
}
paintable.requestPaint!();
}
}
syncCanvasSize();
function uploadContent() {
if (!htmlInCanvas || !contentDirty) return;
contentDirty = false;
gl!.bindTexture(gl!.TEXTURE_2D, contentTexture);
gl!.texImage2D(
gl!.TEXTURE_2D,
0,
gl!.RGBA,
gl!.RGBA,
gl!.UNSIGNED_BYTE,
source,
);
gl!.generateMipmap(gl!.TEXTURE_2D);
}
const trailX = new Float32Array(MAX_TRAIL);
const trailY = new Float32Array(MAX_TRAIL);
const trailData = new Float32Array(MAX_TRAIL * 2);
let headX = output.clientWidth / 2;
let headY = output.clientHeight / 2;
let targetX = headX;
let targetY = headY;
trailX.fill(headX);
trailY.fill(headY);
let presence = 0;
let presenceTarget = 0;
let hasPointer = false;
let time = 0;
function activeCount(): number {
return Math.min(Math.max(Math.round(config.trail), 1), MAX_TRAIL);
}
function render() {
uploadContent();
const dpr = output.width / Math.max(output.clientWidth, 1);
gl!.bindFramebuffer(gl!.FRAMEBUFFER, null);
gl!.viewport(0, 0, output.width, output.height);
gl!.disable(gl!.SCISSOR_TEST);
gl!.clearColor(0, 0, 0, 0);
gl!.clear(gl!.COLOR_BUFFER_BIT);
if (presence <= 0.004) return;
const count = activeCount();
const minRes = Math.min(output.width, output.height);
const headRadius = Math.max(config.size, 4) * dpr * presence;
const baseRadius = (headRadius * 2) / (minRes * count);
const blend = Math.max(config.blend, 0.5);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (let i = 0; i < count; i++) {
const dx = trailX[i] * dpr;
const dy = output.height - trailY[i] * dpr;
trailData[i * 2] = (dx * 2 - output.width) / minRes;
trailData[i * 2 + 1] = (dy * 2 - output.height) / minRes;
minX = Math.min(minX, dx);
maxX = Math.max(maxX, dx);
minY = Math.min(minY, dy);
maxY = Math.max(maxY, dy);
}
const pad =
headRadius +
((Math.log(count + 1) / blend) * minRes) / 2 +
Math.abs(config.refraction) * dpr * 0.5 +
32 * dpr;
const sx = Math.max(0, Math.floor(minX - pad));
const sy = Math.max(0, Math.floor(minY - pad));
gl!.enable(gl!.SCISSOR_TEST);
gl!.scissor(
sx,
sy,
Math.min(output.width - sx, Math.ceil(maxX - minX + pad * 2)),
Math.min(output.height - sy, Math.ceil(maxY - minY + pad * 2)),
);
gl!.useProgram(program);
gl!.activeTexture(gl!.TEXTURE0);
gl!.bindTexture(gl!.TEXTURE_2D, contentTexture);
gl!.uniform1i(uniforms.uContent, 0);
gl!.uniform2f(uniforms.uResolution, output.width, output.height);
gl!.uniform1f(uniforms.uMaxX, contentMaxX);
gl!.uniform1f(uniforms.uDpr, dpr);
gl!.uniform1f(uniforms.uTime, time);
gl!.uniform1f(uniforms.uHasContent, htmlInCanvas ? 1 : 0);
gl!.uniform1i(uniforms.uCount, count);
gl!.uniform2fv(uniforms["uTrail[0]"], trailData);
gl!.uniform1f(uniforms.uBaseRadius, baseRadius);
gl!.uniform1f(uniforms.uBlend, blend);
gl!.uniform1f(uniforms.uRefraction, config.refraction);
gl!.uniform1f(uniforms.uDispersion, Math.max(config.dispersion, 0));
gl!.uniform1f(uniforms.uFrost, Math.min(Math.max(config.frost, 0), 1));
gl!.uniform1f(uniforms.uShine, Math.max(config.shine, 0));
gl!.uniform1f(uniforms.uRim, Math.min(Math.max(config.rim, 0), 2));
gl!.uniform1f(uniforms.uIridescence, Math.max(config.iridescence, 0));
gl!.uniform1f(uniforms.uIntensity, Math.max(config.intensity, 0));
gl!.uniform3f(
uniforms.uTint,
config.tint[0],
config.tint[1],
config.tint[2],
);
gl!.uniform1f(
uniforms.uTintStrength,
Math.min(Math.max(config.tintStrength, 0), 1),
);
gl!.uniform3f(
uniforms.uColorA,
config.colorA[0],
config.colorA[1],
config.colorA[2],
);
gl!.uniform3f(
uniforms.uColorB,
config.colorB[0],
config.colorB[1],
config.colorB[2],
);
gl!.uniform1f(
uniforms.uFallbackAlpha,
Math.min(Math.max(config.fallbackOpacity, 0), 1),
);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
gl!.disable(gl!.SCISSOR_TEST);
}
let raf = 0;
let lastTime = performance.now();
let destroyed = false;
let running = false;
let visible = true;
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let reducedMotion = motionQuery.matches;
function frame(now: number) {
if (destroyed) return;
if (!visible) {
running = false;
return;
}
const delta = Math.min((now - lastTime) / 1000, 1 / 30);
lastTime = now;
if (!reducedMotion) time += delta * Math.max(config.speed, 0);
const follow = Math.min(Math.max(config.follow, 0.02), 1);
const kHead =
reducedMotion || follow >= 1
? 1
: 1 - Math.exp(-delta * (3 + follow * 30));
const kScale = reducedMotion ? 1 : 1 - Math.exp(-delta * 10);
headX += (targetX - headX) * kHead;
headY += (targetY - headY) * kHead;
for (let i = MAX_TRAIL - 1; i > 0; i--) {
trailX[i] = trailX[i - 1];
trailY[i] = trailY[i - 1];
}
trailX[0] = headX;
trailY[0] = headY;
let moved = Math.abs(targetX - headX) + Math.abs(targetY - headY);
for (let i = 1; i < MAX_TRAIL; i++) {
moved = Math.max(
moved,
Math.abs(trailX[i] - trailX[i - 1]) +
Math.abs(trailY[i] - trailY[i - 1]),
);
}
presence += (presenceTarget - presence) * kScale;
render();
const settled = reducedMotion
? moved < 0.1 &&
Math.abs(presenceTarget - presence) < 0.002 &&
!contentDirty
: presence < 0.004 && presenceTarget === 0 && !contentDirty;
if (settled) {
presence = presenceTarget;
running = false;
return;
}
raf = requestAnimationFrame(frame);
}
function start() {
if (destroyed || running || !visible) return;
running = true;
lastTime = performance.now();
raf = requestAnimationFrame(frame);
}
wake = start;
start();
function onPointerMove(event: PointerEvent) {
const rect = output.getBoundingClientRect();
targetX = event.clientX - rect.left;
targetY = event.clientY - rect.top;
if (!hasPointer) {
headX = targetX;
headY = targetY;
trailX.fill(targetX);
trailY.fill(targetY);
hasPointer = true;
}
presenceTarget = 1;
start();
}
function onPointerLeave() {
presenceTarget = 0;
hasPointer = false;
start();
}
content.addEventListener("pointermove", onPointerMove, { passive: true });
content.addEventListener("pointerleave", onPointerLeave, { passive: true });
function onScroll() {
start();
}
content.addEventListener("scroll", onScroll, { passive: true });
function onMotionChange() {
reducedMotion = motionQuery.matches;
start();
}
motionQuery.addEventListener("change", onMotionChange);
const observer = new ResizeObserver(() => {
syncCanvasSize();
start();
});
observer.observe(output);
observer.observe(content);
const intersection = new IntersectionObserver((entries) => {
visible = entries[entries.length - 1]?.isIntersecting ?? true;
if (visible) start();
});
intersection.observe(output);
return {
setOptions(next) {
if (
!Object.entries(next).some(
([key, value]) => config[key as keyof BubbleOptions] !== value,
)
)
return;
Object.assign(config, next);
start();
},
resize() {
syncCanvasSize();
start();
},
destroy() {
destroyed = true;
cancelAnimationFrame(raf);
content.removeEventListener("pointermove", onPointerMove);
content.removeEventListener("pointerleave", onPointerLeave);
content.removeEventListener("scroll", onScroll);
observer.disconnect();
intersection.disconnect();
motionQuery.removeEventListener("change", onMotionChange);
gl!.deleteTexture(contentTexture);
gl!.deleteProgram(program);
gl!.deleteShader(vertexShader);
gl!.deleteShader(fragmentShader);
gl!.deleteBuffer(quad);
if (htmlInCanvas) paintable.onpaint = null;
},
};
}
export interface BubbleProps extends BubbleOptions {
children: ReactNode;
className?: string;
style?: React.CSSProperties;
}
const emptySubscribe = () => () => {};
export function Bubble({
children,
className,
style,
...options
}: BubbleProps) {
const sourceRef = useRef<HTMLCanvasElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const outputRef = useRef<HTMLCanvasElement>(null);
const instanceRef = useRef<BubbleInstance | null>(null);
const [initialOptions] = useState(options);
const [failed, setFailed] = useState(false);
const supported = useSyncExternalStore(
emptySubscribe,
supportsHtmlInCanvas,
() => false,
);
const native = supported && !failed;
useEffect(() => {
const source = sourceRef.current;
const content = contentRef.current;
const output = outputRef.current;
if (!source || !content || !output) return;
instanceRef.current = createBubble(
{ source, content, output },
initialOptions,
);
if (native && !instanceRef.current) setFailed(true);
return () => {
instanceRef.current?.destroy();
instanceRef.current = null;
};
}, [initialOptions, native]);
useEffect(() => {
instanceRef.current?.setOptions(options);
});
return (
<div className={className} style={{ position: "relative", ...style }}>
<canvas
ref={sourceRef}
// @ts-expect-error experimental html-in-canvas attribute
layoutsubtree="true"
suppressHydrationWarning
style={
native
? { position: "absolute", inset: 0, width: "100%", height: "100%" }
: { display: "none" }
}
>
{native ? (
<div
ref={contentRef}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
>
{children}
</div>
) : null}
</canvas>
{!native ? (
<div
ref={contentRef}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
>
{children}
</div>
) : null}
<canvas
ref={outputRef}
aria-hidden
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
/>
</div>
);
}
export default Bubble;
API reference
| Property | Type | Default |
|---|---|---|
sizeRadius of the pooled droplet in CSS pixels. | number | 30 |
trailNumber of spheres in the pointer trail (1 to 24). Each sphere holds the cursor position from a previous frame, so the droplet stretches while moving and pools back together at rest. | number | 24 |
followHow quickly the droplet head follows the cursor (0 to 1). 1 sticks to it. | number | 0.5 |
blendMetaball merge sharpness. Lower values melt the trail into one gooey mass, higher values keep the spheres tighter. | number | 14 |
speedSpeed of the animated liquid sheen rolling over the surface. | number | 2 |
refractionOptical thickness of the droplet in CSS pixels. Light bends through it like water (IOR 1.33), so the page stays untouched at the center and bends progressively toward the edges. Negative values bend outward. | number | 80 |
dispersionChromatic dispersion of the refracted page (0 to 3). 0 disables the color split. | number | 1 |
frostFrosted blur of the page inside the droplet (0 to 1). | number | 0 |
shineSpecular highlight strength (0 to 2). | number | 0.25 |
rimDarkened glassy band around the droplet edge (0 to 2). | number | 0.5 |
iridescenceStrength of the liquid sheen overlaid on the refracted page (0 to 2). | number | 1 |
intensityBrightness of the sheen colors before shaping. Higher values grow the bright hotspots. | number | 0.9 |
tintGlass tint color as RGB in the 0 to 1 range. | [number, number, number] | [1, 1, 1] |
tintStrengthHow strongly the tint colors the refracted page (0 to 1). | number | 0 |
colorAFirst sheen color as RGB in the 0 to 1 range. Also colors the fallback droplet. | [number, number, number] | [0.2902, 0.4549, 0.7216] |
colorBSecond sheen color as RGB in the 0 to 1 range. Also colors the fallback droplet. | [number, number, number] | [0.4118, 0.4118, 0.4157] |
fallbackOpacityOpacity of the droplet when html-in-canvas is unavailable and it renders as a translucent soap-film overlay. | number | 1 |
classNameClasses applied to the wrapper element. | string |