Motion
PreviousNext

Liquid Slider

A liquid glass-effect slider component with smooth animations and customizable appearance.

A slider component that combines the functionality of a traditional range slider with a stunning liquid glass visual effect. The component features realistic refraction, smooth animations, and customizable appearance.

Installation

Install the following dependencies:

We use motion/react for animations and @kit/shared for utility functions.

pnpm add motion/react

Copy and paste the following code into your project.

components/motion/liquid/slider.tsx
'use client';

import { cn } from '@kit/shared';
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react';
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import { LiquidFilter } from './filter';

interface WidthHeight {
    width?: number;
    height?: number;
}

export interface LiquidSliderProps {
    size?: 'xs' | 'sm' | 'md' | 'lg';
    min?: number;
    max?: number;
    defaultValue?: number;
    value?: number;
    onValueChange?: (value: number) => void;
    step?: number;
    disabled?: boolean;
    className?: string;
    style?: React.CSSProperties;
    /**
     * Size of the thumb (overrides size preset if provided)
     */
    thumb?: WidthHeight;
    /**
     * Size of the slider rail (overrides size preset if provided)
     */
    slider?: WidthHeight;
    glassThickness?: number;
    bezelWidth?: number;
    refractiveIndex?: number;
    /**
     * @default false
     */
    forceActive?: boolean;
}

// Size presets
const SLIDER_SIZES = {
    xs: {
        thumb: { height: 20, width: 35 },
        slider: { height: 5, width: 135 },
        glassThickness: 40,
        bezelWidth: 8,
    },
    sm: {
        thumb: { height: 30, width: 52 },
        slider: { height: 7, width: 202 },
        glassThickness: 60,
        bezelWidth: 12,
    },
    md: {
        thumb: { height: 40, width: 70 },
        slider: { height: 10, width: 270 },
        glassThickness: 80,
        bezelWidth: 16,
    },
    lg: {
        thumb: { height: 50, width: 87 },
        slider: { height: 12, width: 337 },
        glassThickness: 100,
        bezelWidth: 20,
    },
} as const;

const SCALE_REST = 0.6;
const SCALE_DRAG = 1;

export const LiquidSlider: React.FC<LiquidSliderProps> = ({
    size = 'md',
    min = 0,
    max = 100,
    defaultValue = 50,
    value: controlledValue,
    onValueChange,
    step = 1,
    disabled = false,
    forceActive = false,
    className,
    style,
    thumb,
    slider,
    glassThickness: customGlassThickness,
    bezelWidth: customBezelWidth,
    refractiveIndex = 1.5, // water is 1.33
}) => {
    // Get size configuration
    const sizeConfig = SLIDER_SIZES[size];

    // Determine final dimensions and properties - custom values override size presets
    const thumbHeight = thumb?.height ?? sizeConfig.thumb.height;
    const thumbWidth = thumb?.width ?? sizeConfig.thumb.width;
    const sliderHeight = slider?.height ?? sizeConfig.slider.height;
    const sliderWidth = slider?.width ?? sizeConfig.slider.width;
    const glassThickness = customGlassThickness ?? sizeConfig.glassThickness;
    const bezelWidth = customBezelWidth ?? sizeConfig.bezelWidth;
    const rawId = useId();
    const filterId = 'slider-thumb_' + rawId;
    const value = useMotionValue(controlledValue ?? defaultValue);

    // Update internal value when controlled value changes
    useEffect(() => {
        if (controlledValue !== undefined) {
            value.set(controlledValue);
        }
    }, [controlledValue, value]);

    const thumbWidthRest = thumbWidth * SCALE_REST;

    const [left, setLeft] = useState(0);
    const computeLeft = useCallback(() => {
        console.warn('COMPUTE LEFT');
        const clampedValue = Math.min(Math.max(value.get(), min), max);
        const ratio = (clampedValue - min) / (max - min); // Convert value to 0-1 ratio
        const trackWidth = sliderWidth - thumbWidth + thumbWidthRest / 3; // Usable track width
        setLeft(ratio * trackWidth - thumbWidthRest / 3);
    }, [value, thumbWidth, min, max, sliderWidth, forceActive]);

    // to avoid double render for controlled input during dragging
    const isDragging = useRef(false);
    const [controlledPosSet, isControlledPosSet] = useState(false);
    useEffect(() => {
        if (!isDragging.current) {
            computeLeft();
            isControlledPosSet(true);
        }
    }, [computeLeft, controlledValue]);

    // Use numeric MotionValue (0/1) instead of boolean for compatibility with transforms
    const pointerDown = useMotionValue(0);

    const isUp = useTransform((): number => (forceActive || pointerDown.get() > 0.5 ? 1 : 0));

    const thumbRadius = thumbHeight / 2;
    // MotionValue-based controls
    const blur = useMotionValue(0); // 0..40
    const specularOpacity = useMotionValue(0.4); // 0..1
    const specularSaturation = useMotionValue(7); // 0..50
    const refractionBase = useMotionValue(1); // 0..1
    const pressMultiplier = useTransform(isUp, [0, 1], [0.4, 0.9]);
    const scaleRatio = useSpring(
        useTransform([pressMultiplier, refractionBase], ([m, base]) => (Number(m) || 0) * (Number(base) || 0))
    );

    const trackRef = useRef<HTMLDivElement>(null);
    const thumbRef = useRef<HTMLDivElement>(null);

    const scaleSpring = useSpring(useTransform(isUp, [0, 1], [SCALE_REST, SCALE_DRAG]), {
        damping: 80,
        stiffness: 2000,
    });

    const backgroundOpacity = useSpring(useTransform(isUp, [0, 1], [1, 0.1]), {
        damping: 80,
        stiffness: 2000,
    });

    // Calculate thumb X position based on value (0-100% -> pixel position)
    // const thumbX = useTransform(value, (v) => {
    //     const ratio = (v - min) / (max - min); // Convert value to 0-1 ratio
    //     const trackWidth = sliderWidth - thumbWidth + thumbWidthRest / 3; // Usable track width
    //     return ratio * trackWidth - thumbWidthRest / 3; // Calculate X position
    // });

    const handlePointerDown = useCallback(() => {
        if (disabled) return;
        pointerDown.set(1);
        isDragging.current = true;
    }, [disabled, pointerDown]);

    const handlePointerUp = useCallback(() => {
        if (disabled) return;
        pointerDown.set(0);
        setTimeout(() => {
            isDragging.current = false;
        }, 100);
    }, [disabled, pointerDown]);

    const handleDragStart = useCallback(() => {
        if (disabled) return;
        pointerDown.set(1);
        isDragging.current = true;
    }, [disabled, pointerDown]);

    const handleDrag = useCallback(() => {
        const track = trackRef.current!.getBoundingClientRect();
        const thumb = thumbRef.current!.getBoundingClientRect();

        const x0 = track.left + thumbWidthRest / 2;
        const x100 = track.right - thumbWidthRest / 2;

        const trackInsideWidth = x100 - x0;

        const thumbCenterX = thumb.left + thumb.width / 2;

        const x = Math.max(x0, Math.min(x100, thumbCenterX));
        const ratio = (x - x0) / trackInsideWidth;

        const newValue = Math.max(min, Math.min(max, ratio * (max - min) + min));
        const steppedValue = Math.round(newValue / step) * step;
        value.set(steppedValue);
        onValueChange?.(steppedValue);
    }, [min, max, step, thumbWidthRest, value, onValueChange]);

    const handleDragEnd = useCallback(() => {
        if (!disabled) return;
        pointerDown.set(0);
        setTimeout(() => {
            isDragging.current = false;
        }, 100);
    }, [disabled, pointerDown]);

    const handleGlobalPointerUp = useCallback(() => {
        pointerDown.set(0);
        setTimeout(() => {
            isDragging.current = false;
        }, 100);
    }, [pointerDown]);

    // End drag when releasing outside the element
    useEffect(() => {
        window.addEventListener('pointerup', handleGlobalPointerUp);
        window.addEventListener('mouseup', handleGlobalPointerUp);
        window.addEventListener('touchend', handleGlobalPointerUp);
        return () => {
            window.removeEventListener('pointerup', handleGlobalPointerUp);
            window.removeEventListener('mouseup', handleGlobalPointerUp);
            window.removeEventListener('touchend', handleGlobalPointerUp);
        };
    }, [handleGlobalPointerUp]);

    const transformedWidth = useTransform(value, (v) => `${v}%`);
    const transformedThumbOpacity = useTransform(backgroundOpacity, (op) => `rgba(255, 255, 255, ${op})`);

    return (
        <div
            className={cn('relative', className)}
            style={{
                width: sliderWidth,
                height: thumbHeight,
                ...style,
            }}
        >
            {(typeof controlledValue === 'number' || typeof defaultValue === 'number') &&
            !controlledPosSet ? null : (
                <>
                    <motion.div
                        ref={trackRef}
                        style={{
                            display: 'inline-block',
                            width: sliderWidth,
                            height: sliderHeight,
                            left: 0,
                            top: (thumbHeight - sliderHeight) / 2,
                            backgroundColor: '#89898F66',
                            borderRadius: sliderHeight / 2,
                            position: 'absolute',
                            cursor: 'pointer',
                        }}
                        onMouseDown={handlePointerDown}
                        onMouseUp={handlePointerUp}
                    >
                        <div className="h-full w-full overflow-hidden rounded-full">
                            <motion.div
                                style={{
                                    top: 0,
                                    left: 0,
                                    height: sliderHeight,
                                    width: transformedWidth,
                                    borderRadius: 6,
                                    backgroundColor: '#0377F7',
                                }}
                            />
                        </div>
                    </motion.div>

                    <motion.div
                        ref={thumbRef}
                        drag={disabled ? false : 'x'}
                        dragConstraints={{
                            left: -thumbWidthRest / 3 - left,
                            right: sliderWidth - thumbWidth + thumbWidthRest / 3 - left,
                        }}
                        dragElastic={0.02}
                        onMouseDown={handlePointerDown}
                        onMouseUp={handlePointerUp}
                        onDragStart={handleDragStart}
                        onDrag={handleDrag}
                        onDragEnd={handleDragEnd}
                        dragMomentum={false}
                        className="absolute"
                        style={{
                            height: thumbHeight,
                            width: thumbWidth,
                            top: 0,
                            left,
                            // x: thumbX,
                            borderRadius: thumbRadius,
                            backdropFilter: `url(#${filterId})`,
                            scale: scaleSpring,
                            cursor: 'pointer',

                            backgroundColor: transformedThumbOpacity,
                            boxShadow: '0 3px 14px rgba(0,0,0,0.1)',
                        }}
                    />
                </>
            )}

            <LiquidFilter
                id={filterId}
                width={thumbWidth}
                height={thumbHeight}
                radius={thumbRadius}
                blur={blur.get()}
                glassThickness={glassThickness}
                bezelWidth={bezelWidth}
                refractiveIndex={refractiveIndex}
                scaleRatio={scaleRatio}
                specularOpacity={specularOpacity.get()}
                specularSaturation={specularSaturation.get()}
            />
        </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

import { LiquidSlider } from '@kit/ui/motion/liquid/slider';

Basic usage

<LiquidSlider
    value={value}
    onValueChange={setValue}
    min={0}
    max={100}
/>

With custom size

<LiquidSlider
    size="lg"
    value={value}
    onValueChange={setValue}
    min={0}
    max={100}
    step={5}
/>

With custom glass properties

<LiquidSlider
    value={value}
    onValueChange={setValue}
    glassThickness={120}
    refractiveIndex={1.8}
    bezelWidth={25}
/>

Examples

Sizes

The component comes with four size presets:

  • xs: Compact size for dense interfaces
  • sm: Small size for secondary controls
  • md: Default medium size
  • lg: Large size for primary controls

Glass Effects

The liquid glass effect is created using SVG filters that simulate:

  • Refraction: Light bending through the glass surface
  • Blur: Realistic glass distortion
  • Specular highlights: Glossy surface reflections
  • Scale transformations: Dynamic size changes on interaction

API Reference

PropTypeDefault
size
"xs" | "sm" | "md" | "lg"
min
number
max
number
defaultValue
number
value
number
onValueChange
function
step
number
disabled
boolean
className
string
style
React.CSSProperties
thumb
WidthHeight
slider
WidthHeight
glassThickness
number
bezelWidth
number
refractiveIndex
number
forceActive
boolean
false

How is this guide?

Last updated on 12/2/2025