Motion
PreviousNext

Liquid Glass

Simulate light refraction respecting the Snell laws. Same effect used in iOS 26.

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.

components/motion/liquid/glass.tsx
'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.

components/motion/liquid/filter.tsx
'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.

lib/motion/liquid/liquid-lib.ts
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:

  1. Wrapper Component: Using <LiquidGlass> as a container
  2. Hook Integration: Using useLiquidSurface() for existing components
  3. Overlay Pattern: Positioning LiquidGlass as an overlay with absolute positioning

Interactive Playground

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

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 useMemo for 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

PropTypeDefault
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 filter
  • filterStyles: CSS styles to apply the glass effect
  • ref: 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);

How is this guide?

Last updated on 12/2/2025