Liquid Glass
Simulate light refraction respecting the Snell laws. Same effect used in iOS 26.
This liquid component is based on the incredible work made by Chris in this blog post : https://kube.io/blog/liquid-glass-css-svg/
This component is experimental, there is still issues that we need to fix to support all different sizes and border radius values.
A comprehensive glass effect system that can transform any content with realistic liquid glass visual effects. Features automatic sizing, responsive updates, and both component and hook-based APIs for maximum flexibility.
Installation
Copy and paste the following code into your project.
'use client';
import { cn } from '@kit/shared';
import { motion, MotionValue, useMotionValue, useSpring, type HTMLMotionProps } from 'motion/react';
import React, { useCallback, useEffect, useId, useLayoutEffect, useRef } from 'react';
import { LiquidFilter, LiquidFilterProps } from './filter';
/**
* Safely parse border radius from computed styles, handling edge cases like
* scientific notation (from rounded-full), percentages, and invalid values.
* For very large values or scientific notation, returns half of the smallest dimension.
*/
const getBorderRadius = (element: HTMLElement, rect: DOMRect): number => {
const computedStyle = getComputedStyle(element);
const rawRadius = computedStyle.borderRadius;
if (!rawRadius || rawRadius === '0px') {
return 0;
}
const parsedRadius = parseFloat(rawRadius);
if (isNaN(parsedRadius)) {
return 0;
}
// Handle scientific notation (e.g., '1.67772e+07px' from rounded-full) or very large values
if (parsedRadius > 9999 || rawRadius.includes('e+') || rawRadius.includes('E+')) {
// For very large values (like rounded-full), return half of smallest dimension
return Math.min(rect.width, rect.height) / 2;
}
return parsedRadius;
};
const useMotionSizeObservers = <T extends HTMLElement = HTMLDivElement>(
containerRef: React.RefObject<T | null>,
disabled: boolean = false
) => {
// Use motion values with built-in spring animations and safe initial values
// Lower stiffness and higher damping to prevent oscillations
const width = useSpring(1, { stiffness: 200, damping: 40 });
const height = useSpring(1, { stiffness: 200, damping: 40 });
const borderRadius = useSpring(0, { stiffness: 200, damping: 40 });
// Ref to prevent infinite update loops
const isUpdating = useRef(false);
// Update dimensions and border radius
const updateDimensions = () => {
if (!containerRef.current || disabled || isUpdating.current) return;
isUpdating.current = true;
const rect = containerRef.current.getBoundingClientRect();
const borderRadiusValue = getBorderRadius(containerRef.current, rect);
// Only update if values have actually changed to prevent infinite loops
const newWidth = Math.max(rect.width, 1);
const newHeight = Math.max(rect.height, 1);
const newRadius = Math.max(borderRadiusValue, 0);
if (Math.abs(width.get() - newWidth) > 0.5) {
width.set(newWidth);
}
if (Math.abs(height.get() - newHeight) > 0.5) {
height.set(newHeight);
}
if (Math.abs(borderRadius.get() - newRadius) > 0.5) {
borderRadius.set(newRadius);
}
// Reset the updating flag after a short delay
setTimeout(() => {
isUpdating.current = false;
}, 16);
};
// Observe size changes
useLayoutEffect(() => {
if (!containerRef.current || disabled) return;
const resizeObserver = new ResizeObserver(() => {
updateDimensions();
});
resizeObserver.observe(containerRef.current);
// Initial measurement
updateDimensions();
return () => {
resizeObserver.disconnect();
};
}, [disabled]);
// Watch for border radius changes through MutationObserver
useEffect(() => {
if (!containerRef.current || disabled) return;
let timeoutId: NodeJS.Timeout;
const mutationObserver = new MutationObserver(() => {
clearTimeout(timeoutId);
timeoutId = setTimeout(updateDimensions, 100); // Debounce mutations
});
mutationObserver.observe(containerRef.current, {
attributes: true,
attributeFilter: ['style', 'class'],
});
return () => {
clearTimeout(timeoutId);
mutationObserver.disconnect();
};
}, [disabled]);
return {
width,
height,
borderRadius,
};
};
export interface LiquidGlassProps<T extends HTMLElement = HTMLDivElement>
extends Pick<
LiquidFilterProps,
| 'glassThickness'
| 'bezelWidth'
| 'blur'
| 'bezelHeightFn'
| 'refractiveIndex'
| 'specularOpacity'
| 'specularSaturation'
| 'dpr'
> {
targetRef?: React.RefObject<T | null>;
width?: MotionValue<number>;
height?: MotionValue<number>;
borderRadius?: MotionValue<number>;
}
export const useLiquidSurface = <T extends HTMLElement = HTMLDivElement>({
targetRef,
width: widthProp,
height: heightProp,
borderRadius: borderRadiusProp,
...props
}: LiquidGlassProps<T>) => {
const filterId = `glass-${useId()}`;
const rawRef = useRef<T>(null);
const ref = targetRef ?? rawRef;
// Use motion value props if provided, otherwise fall back to size observers
const usePropValues = widthProp && heightProp && borderRadiusProp;
const {
width: observedWidth,
height: observedHeight,
borderRadius: observedRadius,
} = useMotionSizeObservers(ref, Boolean(usePropValues));
// Use the provided motion values or the observed ones
const finalWidth = usePropValues ? widthProp : observedWidth;
const finalHeight = usePropValues ? heightProp : observedHeight;
const finalRadius = usePropValues ? borderRadiusProp : observedRadius;
const Filter = () => (
<LiquidFilter id={filterId} width={finalWidth} height={finalHeight} radius={finalRadius} {...props} />
);
const filterStyles: React.CSSProperties = {
backdropFilter: `url(#${filterId})`,
WebkitBackdropFilter: `url(#${filterId})`,
};
return { filterId, filterStyles, ref, Filter };
};
export const LiquidGlass: React.FC<LiquidGlassProps & HTMLMotionProps<'div'>> = ({
children,
glassThickness,
bezelWidth,
blur,
bezelHeightFn,
refractiveIndex,
specularOpacity,
specularSaturation,
dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 1,
targetRef,
width,
height,
borderRadius,
...props
}) => {
const { filterStyles, filterId, Filter, ref } = useLiquidSurface({
glassThickness: glassThickness,
bezelWidth: bezelWidth,
blur: blur,
bezelHeightFn: bezelHeightFn,
refractiveIndex: refractiveIndex,
specularOpacity: specularOpacity,
specularSaturation: specularSaturation,
dpr: dpr,
targetRef,
width,
height,
borderRadius,
});
useEffect(() => {
if (targetRef?.current) {
targetRef.current.style.backdropFilter = `url(#${filterId})`;
}
}, [targetRef]);
return (
<>
<Filter />
{!targetRef && (
<LiquidDiv
{...props}
style={{
...props.style,
...filterStyles,
}}
filterId={filterId}
ref={ref}
>
{children}
</LiquidDiv>
)}
</>
);
};
const LiquidDiv = React.forwardRef<HTMLDivElement, { filterId: string } & HTMLMotionProps<'div'>>(
({ children, filterId, className, ...props }, ref) => {
const isLiquidSupported = useMotionValue(false);
const supportsSVGFilters = useCallback(() => {
const isWebkit = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
const isFirefox = /Firefox/.test(navigator.userAgent);
if (isWebkit || isFirefox) {
return false;
}
const div = document.createElement('div');
div.style.backdropFilter = `url(#${filterId})`;
return div.style.backdropFilter !== '';
}, [filterId]);
useEffect(() => {
const svgSupported = supportsSVGFilters();
if (svgSupported && typeof document !== 'undefined') {
isLiquidSupported.set(true);
}
}, []);
return (
<motion.div
ref={ref}
{...props}
className={cn('bg-white/5', isLiquidSupported ? '' : 'border', className)}
style={{
boxShadow: '0 3px 14px rgba(0,0,0,0.1)',
...props.style,
...(isLiquidSupported
? {}
: {
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}),
}}
>
{children}
</motion.div>
);
}
);
Copy the liquid filter component.
'use client';
import { createCanvas, type ImageData } from 'canvas';
import { motion, MotionValue, useTransform } from 'motion/react';
import { useEffect, useState } from 'react';
import { getDisplacementData } from './liquid-lib';
import { getValueOrMotion } from './liquid-lib';
import { calculateRefractionSpecular } from './liquid-lib';
import { CONVEX } from './liquid-lib';
// function getBezier (bezelType: "convex_circle" | "convex_squircle" | "concave" | "lip") {
// let surfaceFn;
// switch (bezelType) {
// case "convex_circle":
// surfaceFn = CONVEX_CIRCLE.fn;
// break;
// case "convex_squircle":
// surfaceFn = CONVEX.fn;
// break;
// case "concave":
// surfaceFn = CONCAVE.fn;
// break;
// case "lip":
// surfaceFn = LIP.fn;
// break;
// default:
// surfaceFn = CONVEX.fn;
// }
// return surfaceFn;
// }
function imageDataToUrl(imageData: ImageData): string {
const canvas = createCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL();
}
export type LiquidFilterProps = {
id: string;
filterOnly?: boolean;
scaleRatio?: MotionValue<number>;
canvasWidth?: number | MotionValue<number>;
canvasHeight?: number | MotionValue<number>;
width: number | MotionValue<number>;
height: number | MotionValue<number>;
radius: number | MotionValue<number>;
/**
* SVG Gauss gradient applied
* @default 0.2
*/
blur?: number | MotionValue<number>;
/**
* Glass tickess.
* Bigger this value is, longer will be the translations.
* @default 40
*/
glassThickness?: number | MotionValue<number>;
/**
* Width of the non-flat glass surface at the boundaries.
* @default 20
*/
bezelWidth?: number | MotionValue<number>;
/**
* Value used in the snell law: n1 sin(θ1) = n2 sin(θ2)
* Water is 1.33
*
* @default 1.5
*/
refractiveIndex?: number | MotionValue<number>;
/**
* Opacity of the border
* @default 0.4
*/
specularOpacity?: number | MotionValue<number>;
/**
* @default 4
*/
specularSaturation?: number | MotionValue<number>;
dpr?: number | MotionValue<number>;
/**
* Set the profile of the edges.
* @default CONVEX.fn
*/
bezelHeightFn?: (x: number) => number;
// bezelType?: 'convex_circle' | 'convex_squircle' | 'concave' | 'lip';
};
export const LiquidFilter: React.FC<LiquidFilterProps> = ({
id,
filterOnly = false,
canvasWidth,
canvasHeight,
width,
height,
radius,
blur = 0.2,
glassThickness = 40,
bezelWidth: bezelWidthProp = 20,
refractiveIndex = 1.5,
scaleRatio,
specularOpacity = 1,
specularSaturation = 4,
bezelHeightFn = CONVEX.fn,
dpr,
}) => {
// Hydration fix: only render on client to avoid SSR/client mismatch with dynamic canvas data
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const displacementData = useTransform(() => {
const canvasW = canvasWidth ? getValueOrMotion(canvasWidth) : getValueOrMotion(width);
const canvasH = canvasHeight ? getValueOrMotion(canvasHeight) : getValueOrMotion(height);
const devicePixelRatio = dpr ? getValueOrMotion(dpr) : 1;
const clampedBezelWidth = Math.max(Math.min(getValueOrMotion(bezelWidthProp), 2 * getValueOrMotion(radius) - 1), 0);
return getDisplacementData({
glassThickness: getValueOrMotion(glassThickness),
bezelWidth: clampedBezelWidth,
bezelHeightFn,
refractiveIndex: getValueOrMotion(refractiveIndex),
canvasWidth: canvasW,
canvasHeight: canvasH,
objectWidth: getValueOrMotion(width),
objectHeight: getValueOrMotion(height),
radius: getValueOrMotion(radius),
dpr: devicePixelRatio,
});
});
const specularLayer = useTransform(() => {
const devicePixelRatio = dpr ? getValueOrMotion(dpr) : 1;
return calculateRefractionSpecular(
getValueOrMotion(width),
getValueOrMotion(height),
getValueOrMotion(radius),
50,
undefined,
devicePixelRatio
);
});
const displacementMapDataUrl = useTransform(() => {
return imageDataToUrl(displacementData.get().displacementMap);
});
const specularLayerDataUrl = useTransform(() => {
return imageDataToUrl(specularLayer.get());
});
const scale = useTransform(() => displacementData.get().maximumDisplacement * (scaleRatio?.get() ?? 1));
const content = (
<filter id={id}>
<motion.feGaussianBlur
in={'SourceGraphic'}
stdDeviation={typeof blur === 'object' && 'get' in blur ? blur : useTransform(() => blur as number)}
result="blurred_source"
/>
<motion.feImage
href={displacementMapDataUrl}
x={0}
y={0}
width={useTransform(() => (canvasWidth ? getValueOrMotion(canvasWidth) : getValueOrMotion(width)))}
height={useTransform(() => (canvasHeight ? getValueOrMotion(canvasHeight) : getValueOrMotion(height)))}
result="displacement_map"
/>
<motion.feDisplacementMap
in="blurred_source"
in2="displacement_map"
scale={scale}
xChannelSelector="R"
yChannelSelector="G"
result="displaced"
/>
<motion.feColorMatrix
in="displaced"
type="saturate"
values={useTransform(() => getValueOrMotion(specularSaturation).toString()) as any}
result="displaced_saturated"
/>
<motion.feImage
href={specularLayerDataUrl}
x={0}
y={0}
width={useTransform(() => (canvasWidth ? getValueOrMotion(canvasWidth) : getValueOrMotion(width)))}
height={useTransform(() => (canvasHeight ? getValueOrMotion(canvasHeight) : getValueOrMotion(height)))}
result="specular_layer"
/>
<feComposite
in="displaced_saturated"
in2="specular_layer"
operator="in"
result="specular_saturated"
/>
<feComponentTransfer in="specular_layer" result="specular_faded">
<motion.feFuncA
type="linear"
slope={useTransform(() => getValueOrMotion(specularOpacity))}
/>
</feComponentTransfer>
<motion.feBlend in="specular_saturated" in2="displaced" mode="normal" result="withSaturation" />
<motion.feBlend in="specular_faded" in2="withSaturation" mode="normal" />
</filter>
);
// Return null during SSR to prevent hydration mismatch
if (!isMounted) {
return null;
}
return filterOnly ? (
content
) : (
<svg colorInterpolationFilters="sRGB" style={{ display: 'none' }}>
<defs>{content}</defs>
</svg>
);
};
Copy the liquid utilities library.
import { createImageData } from 'canvas';
import { MotionValue } from 'motion';
function calculateRefractionProfile(
glassThickness: number = 200,
bezelWidth: number = 50,
bezelHeightFn: (x: number) => number = (x) => x,
refractiveIndex: number = 1.5,
samples: number = 128
): number[] {
// Pre-calculate the distance the ray will be deviated
// given the distance to border (ratio of bezel)
// and height of the glass
const eta = 1 / refractiveIndex;
// Simplified refraction, which only handles fully vertical incident ray [0, 1]
function refract(normalX: number, normalY: number): [number, number] | null {
const dot = normalY;
const k = 1 - eta * eta * (1 - dot * dot);
if (k < 0) {
// Total internal reflection
return null;
}
const kSqrt = Math.sqrt(k);
return [-(eta * dot + kSqrt) * normalX, eta - (eta * dot + kSqrt) * normalY];
}
return Array.from({ length: samples }, (_, i) => {
const x = i / samples;
const y = bezelHeightFn(x);
// Calculate derivative in x
const dx = x < 1 ? 0.0001 : -0.0001;
const y2 = bezelHeightFn(x + dx);
const derivative = (y2 - y) / dx;
const magnitude = Math.sqrt(derivative * derivative + 1);
const normal: [number, number] = [-derivative / magnitude, -1 / magnitude];
const refracted = refract(normal[0], normal[1]);
if (!refracted) {
return 0;
} else {
const remainingHeightOnBezel = y * bezelWidth;
const remainingHeight = remainingHeightOnBezel + glassThickness;
// Return displacement (rest of travel on x-axis, depends on remaining height to hit bottom of glass)
return refracted[0] * (remainingHeight / refracted[1]);
}
});
}
function generateDisplacementImageData(
canvasWidth: number,
canvasHeight: number,
objectWidth: number,
objectHeight: number,
radius: number,
bezelWidth: number,
maximumDisplacement: number,
refractionProfile: number[] = [],
dpr?: number
) {
const devicePixelRatio = dpr ?? (typeof window !== 'undefined' ? (window.devicePixelRatio ?? 1) : 1);
const bufferWidth = canvasWidth * devicePixelRatio;
const bufferHeight = canvasHeight * devicePixelRatio;
// console.log( {bufferWidth, bufferHeight} )
const imageData = createImageData(bufferWidth, bufferHeight);
// Fill neutral color using buffer
const neutral = 0xff008080;
new Uint32Array(imageData.data.buffer).fill(neutral);
const radius_ = radius * devicePixelRatio;
const bezel = bezelWidth * devicePixelRatio;
const radiusSquared = radius_ ** 2;
const radiusPlusOneSquared = (radius_ + 1) ** 2;
const radiusMinusBezelSquared = (radius_ - bezel) ** 2;
const objectWidth_ = objectWidth * devicePixelRatio;
const objectHeight_ = objectHeight * devicePixelRatio;
const widthBetweenRadiuses = objectWidth_ - radius_ * 2;
const heightBetweenRadiuses = objectHeight_ - radius_ * 2;
const objectX = (bufferWidth - objectWidth_) / 2;
const objectY = (bufferHeight - objectHeight_) / 2;
for (let y1 = 0; y1 < objectHeight_; y1++) {
for (let x1 = 0; x1 < objectWidth_; x1++) {
const idx = ((objectY + y1) * bufferWidth + objectX + x1) * 4;
const isOnLeftSide = x1 < radius_;
const isOnRightSide = x1 >= objectWidth_ - radius_;
const isOnTopSide = y1 < radius_;
const isOnBottomSide = y1 >= objectHeight_ - radius_;
const x = isOnLeftSide ? x1 - radius_ : isOnRightSide ? x1 - radius_ - widthBetweenRadiuses : 0;
const y = isOnTopSide ? y1 - radius_ : isOnBottomSide ? y1 - radius_ - heightBetweenRadiuses : 0;
const distanceToCenterSquared = x * x + y * y;
const isInBezel =
distanceToCenterSquared <= radiusPlusOneSquared &&
distanceToCenterSquared >= radiusMinusBezelSquared;
// Only write non-neutral displacements (when isInBezel)
if (isInBezel) {
const opacity =
distanceToCenterSquared < radiusSquared
? 1
: 1 -
(Math.sqrt(distanceToCenterSquared) - Math.sqrt(radiusSquared)) /
(Math.sqrt(radiusPlusOneSquared) - Math.sqrt(radiusSquared));
const distanceFromCenter = Math.sqrt(distanceToCenterSquared);
const distanceFromSide = radius_ - distanceFromCenter;
// Viewed from top
const cos = x / distanceFromCenter;
const sin = y / distanceFromCenter;
const bezelIndex = ((distanceFromSide / bezel) * refractionProfile.length) | 0;
const distance = refractionProfile[bezelIndex] ?? 0;
const dX = (-cos * distance) / maximumDisplacement;
const dY = (-sin * distance) / maximumDisplacement;
imageData.data[idx] = 128 + dX * 127 * opacity; // R
imageData.data[idx + 1] = 128 + dY * 127 * opacity; // G
imageData.data[idx + 2] = 0; // B
imageData.data[idx + 3] = 255; // A
}
}
}
return imageData;
}
export const getDisplacementData = ({
glassThickness = 200,
bezelWidth = 50,
bezelHeightFn = (x) => x,
refractiveIndex = 1.5,
samples = 128,
canvasWidth,
canvasHeight,
objectWidth,
objectHeight,
radius,
dpr,
}: {
glassThickness?: number;
bezelWidth?: number;
bezelHeightFn?: (x: number) => number;
refractiveIndex?: number;
samples?: number;
canvasWidth: number;
canvasHeight: number;
objectWidth: number;
objectHeight: number;
radius: number;
dpr?: number;
}) => {
const refractionProfile = calculateRefractionProfile(
glassThickness,
bezelWidth,
bezelHeightFn,
refractiveIndex,
samples
);
const maximumDisplacement = Math.max(...refractionProfile.map((v) => Math.abs(v)));
const displacementMap = generateDisplacementImageData(
canvasWidth,
canvasHeight,
objectWidth,
objectHeight,
radius,
bezelWidth,
maximumDisplacement,
refractionProfile,
dpr
);
return {
displacementMap,
maximumDisplacement,
};
};
export function calculateRefractionSpecular(
objectWidth: number,
objectHeight: number,
radius: number,
bezelWidth: number,
specularAngle = Math.PI / 3,
dpr?: number
) {
const devicePixelRatio = dpr ?? (typeof window !== 'undefined' ? (window.devicePixelRatio ?? 1) : 1);
const bufferWidth = objectWidth * devicePixelRatio;
const bufferHeight = objectHeight * devicePixelRatio;
const imageData = createImageData(bufferWidth, bufferHeight);
const radius_ = radius * devicePixelRatio;
const bezel_ = bezelWidth * devicePixelRatio;
// Vector along which we should see specular
const specular_vector = [Math.cos(specularAngle), Math.sin(specularAngle)];
// Fill neutral color using buffer
const neutral = 0x00000000;
new Uint32Array(imageData.data.buffer).fill(neutral);
const radiusSquared = radius_ ** 2;
const radiusPlusOneSquared = (radius_ + devicePixelRatio) ** 2;
const radiusMinusBezelSquared = (radius_ - bezel_) ** 2;
const widthBetweenRadiuses = bufferWidth - radius_ * 2;
const heightBetweenRadiuses = bufferHeight - radius_ * 2;
for (let y1 = 0; y1 < bufferHeight; y1++) {
for (let x1 = 0; x1 < bufferWidth; x1++) {
const idx = (y1 * bufferWidth + x1) * 4;
const isOnLeftSide = x1 < radius_;
const isOnRightSide = x1 >= bufferWidth - radius_;
const isOnTopSide = y1 < radius_;
const isOnBottomSide = y1 >= bufferHeight - radius_;
const x = isOnLeftSide ? x1 - radius_ : isOnRightSide ? x1 - radius_ - widthBetweenRadiuses : 0;
const y = isOnTopSide ? y1 - radius_ : isOnBottomSide ? y1 - radius_ - heightBetweenRadiuses : 0;
const distanceToCenterSquared = x * x + y * y;
const isInBezel =
distanceToCenterSquared <= radiusPlusOneSquared &&
distanceToCenterSquared >= radiusMinusBezelSquared;
// Process pixels that are in bezel or near bezel edge for anti-aliasing
if (isInBezel) {
const distanceFromCenter = Math.sqrt(distanceToCenterSquared);
const distanceFromSide = radius_ - distanceFromCenter;
const opacity =
distanceToCenterSquared < radiusSquared
? 1
: 1 -
(distanceFromCenter - Math.sqrt(radiusSquared)) /
(Math.sqrt(radiusPlusOneSquared) - Math.sqrt(radiusSquared));
// Viewed from top
const cos = x / distanceFromCenter;
const sin = -y / distanceFromCenter;
// Dot product of orientation
const dotProduct = Math.abs(cos * specular_vector[0]! + sin * specular_vector[1]!);
const coefficient =
dotProduct * Math.sqrt(1 - (1 - distanceFromSide / (1 * devicePixelRatio)) ** 2);
const color = 255 * coefficient;
const finalOpacity = color * coefficient * opacity;
imageData.data[idx] = color;
imageData.data[idx + 1] = color;
imageData.data[idx + 2] = color;
imageData.data[idx + 3] = finalOpacity;
}
}
}
return imageData;
}
export function getValueOrMotion<T>(value: T | MotionValue<T>): T {
return value instanceof MotionValue ? value.get() : value;
}
// equations
export type SurfaceFnDef = {
title: string;
fn: (x: number) => number;
};
export const CONVEX_CIRCLE: SurfaceFnDef = {
title: 'Convex Circle',
fn: (x) => Math.sqrt(1 - (1 - x) ** 2),
};
export const CONVEX: SurfaceFnDef = {
title: 'Convex Squircle',
fn: (x) => Math.pow(1 - Math.pow(1 - x, 4), 1 / 4),
};
export const CONCAVE: SurfaceFnDef = {
title: 'Concave',
fn: (x) => 1 - CONVEX_CIRCLE.fn(x),
};
export const LIP: SurfaceFnDef = {
title: 'Lip',
fn: (x) => {
const convex = CONVEX.fn(x * 2);
const concave = CONCAVE.fn(x) + 0.1;
const smootherstep = 6 * x ** 5 - 15 * x ** 4 + 10 * x ** 3;
return convex * (1 - smootherstep) + concave * smootherstep;
},
};
export const WAVE: SurfaceFnDef = {
title: 'Wave',
fn: (x) => {
const base = Math.pow(x, 0.5);
const wave = Math.sin(x * Math.PI * 3) * 0.1;
return Math.max(0, Math.min(1, base + wave));
},
};
export const STEPPED: SurfaceFnDef = {
title: 'Stepped',
fn: (x) => {
const steps = 4;
const stepSize = 1 / steps;
const stepIndex = Math.floor(x / stepSize);
const stepProgress = (x % stepSize) / stepSize;
const stepHeight = stepIndex / (steps - 1);
const smoothing = Math.pow(stepProgress, 3) * (stepProgress * (stepProgress * 6 - 15) + 10);
return stepHeight + smoothing * (1 / (steps - 1));
},
};
export const ELASTIC: SurfaceFnDef = {
title: 'Elastic',
fn: (x) => {
if (x === 0) return 0;
if (x === 1) return 1;
const p = 0.3;
const s = p / 4;
return Math.pow(2, -10 * x) * Math.sin(((x - s) * (2 * Math.PI)) / p) + 1;
},
};
export const BUBBLE: SurfaceFnDef = {
title: 'Bubble',
fn: (x) => {
const center = 0.6;
const width = 0.4;
const height = 1.2;
const distance = Math.abs(x - center) / width;
if (distance > 1) return 0;
const bubble = Math.sqrt(1 - distance * distance) * height;
const base = Math.pow(x, 2);
return Math.max(0, Math.min(1, Math.max(base, bubble)));
},
};
export const fns: SurfaceFnDef[] = [CONVEX_CIRCLE, CONVEX, CONCAVE, LIP, WAVE, STEPPED, ELASTIC, BUBBLE];
Update the import paths to match your project setup.
Usage
Component API
import { LiquidGlass } from '@kit/ui/motion/liquid/glass';Basic usage
<LiquidGlass className="rounded-xl p-6">
<h2>Glass Content</h2>
<p>This content appears behind realistic glass.</p>
</LiquidGlass>Hook API
import { useLiquidSurface } from '@kit/ui/motion/liquid/glass';Hook basic usage
function FlexibleGlass() {
const { Filter, filterStyles, ref } = useLiquidSurface({
glassThickness: 50,
});
return (
<>
<Filter />
<div ref={ref} style={filterStyles} className="p-4 rounded-lg">
Content with glass effect
</div>
</>
);
}Examples
Form Integration
This example demonstrates how to integrate LiquidGlass with form components like inputs, textareas, and select dropdowns for a cohesive glass effect throughout your forms.
Integration Patterns
The examples show three main integration patterns:
- Wrapper Component: Using
<LiquidGlass>as a container - Hook Integration: Using
useLiquidSurface()for existing components - Overlay Pattern: Positioning LiquidGlass as an overlay with absolute positioning
Interactive Playground
Liquid Glass
Adjust the controls below to see how different properties affect the glass appearance and refraction.
Dimensions
Glass Properties
Experiment with different glass properties in real-time to understand how each parameter affects the liquid glass appearance. The playground includes:
- Visual feedback: See changes instantly as you adjust sliders
- Background imagery: Rich background to showcase refraction effects
- Preset configurations: Quick access to light, medium, and heavy glass effects
- Parameter explanations: Understand what each property controls
Surface Functions
Different surface functions create unique glass edge profiles. Each function defines how the bezel height changes from edge to center:
Convex Circle
Convex Squircle
Concave
Lip
Wave
Stepped
Elastic
Bubble
Browser Compatibility
Chrome | Edge | Safari | Firefox | Opera |
|---|---|---|---|---|
Only chromium browsers support usage of a svg filter with the backdrop filter property. It represent about 75% of the users in 2025. Meaning the liquid feature won't work on safari (about 18% in 2025) and firefox (2.6% in 2025).
Troubleshooting
FPS impact
It is important to be aware that a too big LiquidGlass component will consume to much ressources and may slow down your website.
Glass Effect Not Visible
If you can't see the glass effect, check these common issues:
Border Radius Requirement
The liquid glass component requires a border radius to function properly. Without it, the glass effect may not render correctly.
// ❌ Won't work properly - no border radius
<LiquidGlass className="p-4">
Content
</LiquidGlass>
// ✅ Works correctly - has border radius
<LiquidGlass className="p-4 rounded-lg">
Content
</LiquidGlass>Minimum Border Radius for Spectacle Effect
For the spectacular border effect to work properly, you need a minimum border radius of 26px:
// ❌ Too small - spectacle effect may not work <LiquidGlass className="p-4 rounded-sm"> {/* 2px radius */} Content </LiquidGlass> // ⚠️ Minimal - basic glass effect only <LiquidGlass className="p-4 rounded-xl"> {/* 12px radius */} Content </LiquidGlass> // ✅ Optimal - full spectacle effect <LiquidGlass className="p-4" style={{ borderRadius: 26 }} > Content </LiquidGlass>
Performance Issues
If you experience performance issues:
- Reduce
glassThickness- Lower values require less computation - Decrease
blur- High blur values can impact performance - Limit concurrent instances - Multiple glass components may affect performance
- Use
useMemofor complex content inside the glass component
Glass Effect Appears Distorted
If the glass effect looks wrong:
- Check container dimensions - Ensure the component has proper width/height
- Verify border radius - Very large radius values relative to size can cause issues
- Adjust
bezelWidth- Should be proportional to the component size - Test
refractiveIndex- Extreme values (< 1.0 or > 3.0) may cause artifacts
API Reference
LiquidGlass Component
| Prop | Type | Default |
|---|---|---|
targetRef | React.RefObject<T | null> | |
width | MotionValue<number> | |
height | MotionValue<number> | |
borderRadius | MotionValue<number> | |
glassThickness | number | MotionValue<number> | 40 |
bezelWidth | number | MotionValue<number> | 20 |
blur | number | MotionValue<number> | 0.2 |
bezelHeightFn | function | CONVEX.fn |
refractiveIndex | number | MotionValue<number> | 1.5 |
specularOpacity | number | MotionValue<number> | 0.4 |
specularSaturation | number | MotionValue<number> | 4 |
dpr | number | MotionValue<number> |
useLiquidSurface Hook
The hook returns an object with:
Filter: React component to render the SVG filterfilterStyles: CSS styles to apply the glass effectref: Ref to attach to the target element (if no targetRef provided)filterId: Unique ID of the generated filter
const { Filter, filterStyles, ref, filterId } = useLiquidSurface(props);Add a glowing effect to your components.
A liquid glass-effect slider component with smooth animations and customizable appearance.
How is this guide?
Last updated on 12/2/2025