Motion
PreviousNext

Liquid Switch

A toggle switch component with liquid glass visual effects and smooth animations.

A toggle switch component that combines standard switch functionality with stunning liquid glass visual effects. Features realistic refraction, smooth animations, and intuitive drag-to-toggle interactions.

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/switch.tsx
'use client';

import { mix, motion, useMotionValue, useSpring, useTransform } from 'motion/react';
import type { CSSProperties, FC } from 'react';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { LiquidFilter } from './filter';
import { LIP } from './liquid-lib';
import { cn } from '@kit/shared';

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

export interface LiquidSwitchProps {
    size?: 'sm' | 'md' | 'lg' | 'xl';
    checked?: boolean;
    defaultChecked?: boolean;
    onCheckedChange?: (checked: boolean) => void;
    disabled?: boolean;
    className?: string;
    style?: 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;
    blur?: number;
    specularOpacity?: number;
    specularSaturation?: number;
    refractionBase?: number;
}

// Size presets
const SWITCH_SIZES = {
    sm : {
        thumb: { height: 30, width: 48 },
        slider: { height: 24, width: 60 },
        glassThickness: 24,
        bezelWidth: 10,
    },
    md : {
        thumb: { height: 46, width: 73 },
        slider: { height: 34, width: 80 },
        glassThickness: 24,
        bezelWidth: 10,
    },
    lg: {
        thumb: { height: 69, width: 109 },
        slider: { height: 50, width: 120 },
        glassThickness: 35,
        bezelWidth: 14,
    },
    xl: {
        thumb: { height: 92, width: 146 },
        slider: { height: 67, width: 160 },
        glassThickness: 47,
        bezelWidth: 19,
    },
    // lg: {
    //     thumb: { height: 115, width: 182 },
    //     slider: { height: 84, width: 200 },
    //     glassThickness: 59,
    //     bezelWidth: 24,
    // },
} as const;

const THUMB_REST_SCALE = 0.65;
const THUMB_ACTIVE_SCALE = 0.9;

export const LiquidSwitch: FC<LiquidSwitchProps> = ({
    checked: controlledChecked,
    defaultChecked = false,
    onCheckedChange,
    disabled = false,
    forceActive = false,
    size = 'md',
    className,
    style,
    thumb,
    slider,
    glassThickness: customGlassThickness,
    bezelWidth: customBezelWidth,
    refractiveIndex = 1.5,
    blur = 0.2,
    specularOpacity = 0.5,
    specularSaturation = 6,
    refractionBase: refractionBaseProp = 1,
}) => {
    // Get size configuration (fallback to 'md' if custom dimensions are provided without size)
    const sizeConfig = SWITCH_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 = 'switch-thumb_' + rawId
    const thumbRadius = thumbHeight / 2;
    const sliderRef = useRef<HTMLDivElement>(null);

    // Internal state for checked value
    const [internalChecked, setInternalChecked] = useState(controlledChecked ?? defaultChecked);
    const isControlled = controlledChecked !== undefined;
    const checked = isControlled ? controlledChecked : internalChecked;

    // Glass effect controls
    const refractionBase = useMotionValue(refractionBaseProp);
    const xDragRatio = useMotionValue(0);

    const THUMB_REST_OFFSET = ((1 - THUMB_REST_SCALE) * thumbWidth) / 2;
    const TRAVEL = sliderWidth - sliderHeight - (thumbWidth - thumbHeight) * THUMB_REST_SCALE;

    // Motion sources
    const checkedMotion = useMotionValue(checked ? 1 : 0);
    const pointerDown = useMotionValue(0);
    const initialPointerX = useMotionValue(0);
    const active = useTransform(() => (forceActive || pointerDown.get() > 0.5 ? 1 : 0));

    // Update motion value when checked prop changes
    useEffect(() => {
        checkedMotion.set(checked ? 1 : 0);
    }, [checked, checkedMotion]);

    // Event handlers
    const handleToggle = useCallback((newChecked: boolean) => {
        if (disabled) return;

        if (!isControlled) {
            setInternalChecked(newChecked);
        }
        onCheckedChange?.(newChecked);
    }, [disabled, isControlled, onCheckedChange]);

    const handlePointerDown = useCallback((e: React.MouseEvent | React.TouchEvent) => {
        if (disabled) return;
        e.stopPropagation();
        
        const clientX = 'touches' in e ? e.touches[0]?.clientX ?? 0 : e.clientX;
        pointerDown.set(1);
        initialPointerX.set(clientX);
    }, [disabled, pointerDown, initialPointerX]);

    const handleMouseMove = useCallback((e: React.MouseEvent) => {
        if (!sliderRef.current || disabled || pointerDown.get() < 0.5) return;
        e.stopPropagation();
        
        const baseRatio = checkedMotion.get();
        const displacementX = e.clientX - initialPointerX.get();
        const ratio = baseRatio + displacementX / TRAVEL;
        const overflow = ratio < 0 ? -ratio : ratio > 1 ? ratio - 1 : 0;
        const overflowSign = ratio < 0 ? -1 : 1;
        const dampedOverflow = (overflowSign * overflow) / 22;
        xDragRatio.set(Math.min(1, Math.max(0, ratio)) + dampedOverflow);
    }, [disabled, pointerDown, checkedMotion, initialPointerX, TRAVEL, xDragRatio]);

    const handleTouchMove = useCallback((e: React.TouchEvent) => {
        if (!sliderRef.current || disabled || pointerDown.get() < 0.5) return;
        e.stopPropagation();
        
        const baseRatio = checkedMotion.get();
        const clientX = e.touches[0]?.clientX ?? 0;
        const displacementX = clientX - initialPointerX.get();
        const ratio = baseRatio + displacementX / TRAVEL;
        const overflow = ratio < 0 ? -ratio : ratio > 1 ? ratio - 1 : 0;
        const overflowSign = ratio < 0 ? -1 : 1;
        const dampedOverflow = (overflowSign * overflow) / 22;
        xDragRatio.set(Math.min(1, Math.max(0, ratio)) + dampedOverflow);
    }, [disabled, pointerDown, checkedMotion, initialPointerX, TRAVEL, xDragRatio]);

    const handleClick = useCallback((e: React.MouseEvent) => {
        if (disabled) return;
        
        const x = e.clientX;
        const initialX = initialPointerX.get();
        const distance = x - initialX;
        if (Math.abs(distance) < 4) {
            const shouldBeChecked = checkedMotion.get() < 0.5;
            handleToggle(shouldBeChecked);
        }
    }, [disabled, initialPointerX, checkedMotion, handleToggle]);

    const handleGlobalPointerUp = useCallback((e: MouseEvent | TouchEvent) => {
        pointerDown.set(0);

        const x = e instanceof MouseEvent ? e.clientX : e.changedTouches[0]?.clientX ?? 0;
        const distance = x - initialPointerX.get();
        
        if (Math.abs(distance) > 4) {
            const dragRatio = xDragRatio.get();
            const shouldBeChecked = dragRatio > 0.5;
            handleToggle(shouldBeChecked);
        }
    }, [pointerDown, initialPointerX, xDragRatio, handleToggle]);

    // Global pointer up listener
    useEffect(() => {
        window.addEventListener('mouseup', handleGlobalPointerUp);
        window.addEventListener('touchend', handleGlobalPointerUp);
        return () => {
            window.removeEventListener('mouseup', handleGlobalPointerUp);
            window.removeEventListener('touchend', handleGlobalPointerUp);
        };
    }, [handleGlobalPointerUp]);

    //
    // SPRINGS
    //
    const xRatio = useSpring(
        useTransform(() => {
            const c = checkedMotion.get();
            const dragRatio = xDragRatio.get();

            if (pointerDown.get() > 0.5) {
                return dragRatio;
            } else {
                return c ? 1 : 0;
            }
        }),
        { damping: 80, stiffness: 1000 }
    );
    const backgroundOpacity = useSpring(
        useTransform(active, (v) => 1 - 0.9 * v),
        { damping: 80, stiffness: 2000 }
    );
    const thumbScale = useSpring(
        useTransform(active, (v) => THUMB_REST_SCALE + (THUMB_ACTIVE_SCALE - THUMB_REST_SCALE) * v),
        { damping: 80, stiffness: 2000 }
    );
    const scaleRatio = useSpring(useTransform(() => (0.4 + 0.5 * active.get()) * refractionBase.get()));
    const considerChecked = useTransform(() => {
        const x = xDragRatio.get();
        const c = checkedMotion.get();
        return pointerDown.get() ? (x > 0.5 ? 1 : 0) : c > 0.5 ? 1 : (0 as number);
    });

    const backgroundColor = useTransform(
        useSpring(considerChecked, { damping: 80, stiffness: 1000 }),
        mix('#94949F77', '#3BBF4EEE')
    );

    return (
        <div
            className={cn('relative',className)}
            style={{
                width: sliderWidth,
                // height: thumbHeight,
                height: sliderHeight,
                ...style,
            }}
            onMouseMove={handleMouseMove}
            onTouchMove={handleTouchMove}
        >
            <motion.div
                ref={sliderRef}
                style={{
                    display: 'inline-block',
                    width: sliderWidth,
                    height: sliderHeight,
                    backgroundColor: backgroundColor,
                    borderRadius: sliderHeight / 2,
                    position: 'absolute',
                    top: 0,
                    // top: (thumbHeight - sliderHeight) / 2,
                    cursor: 'pointer',
                }}
                onClick={handleClick}
            >
                <LiquidFilter
                    id={filterId}
                    width={thumbWidth}
                    height={thumbHeight}
                    radius={thumbRadius}
                    blur={blur}
                    glassThickness={glassThickness}
                    bezelWidth={bezelWidth}
                    refractiveIndex={refractiveIndex}
                    scaleRatio={scaleRatio}
                    specularOpacity={specularOpacity}
                    specularSaturation={specularSaturation}
                    bezelHeightFn={LIP.fn}
                />
                <motion.div
                    className="absolute"
                    onTouchStart={handlePointerDown}
                    onMouseDown={handlePointerDown}
                    style={{
                        height: thumbHeight,
                        width: thumbWidth,
                        marginLeft: -THUMB_REST_OFFSET + (sliderHeight - thumbHeight * THUMB_REST_SCALE) / 2,
                        x: useTransform(() => xRatio.get() * TRAVEL),
                        y: '-50%',
                        borderRadius: thumbRadius,
                        top: sliderHeight / 2,
                        backdropFilter: `url(#${filterId})`,
                        scale: thumbScale,
                        cursor: 'pointer',
                        backgroundColor: useTransform(
                            backgroundOpacity,
                            (op) => `rgba(255, 255, 255, ${op})`
                        ),
                        boxShadow: useTransform(() => {
                            const isPressed = pointerDown.get() > 0.5;
                            return (
                                '0 4px 22px rgba(0,0,0,0.1)' +
                                (isPressed
                                    ? ', inset 2px 7px 24px rgba(0,0,0,0.09), inset -2px -7px 24px rgba(255,255,255,0.09)'
                                    : '')
                            );
                        }),
                    }}
                />
            </motion.div>
        </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 { LiquidSwitch } from '@kit/ui/motion/liquid/switch';

Basic usage

<LiquidSwitch
    checked={checked}
    onCheckedChange={setChecked}
/>

Uncontrolled with default state

<LiquidSwitch
    defaultChecked={true}
    onCheckedChange={(checked) => console.log('Switched to:', checked)}
/>

Different sizes

<LiquidSwitch size="xs" defaultChecked />
<LiquidSwitch size="sm" defaultChecked />
<LiquidSwitch size="md" defaultChecked />
<LiquidSwitch size="lg" defaultChecked />

Custom glass properties

<LiquidSwitch
    checked={checked}
    onCheckedChange={setChecked}
    glassThickness={60}
    refractiveIndex={1.8}
    specularOpacity={0.7}
/>

Disabled state

<LiquidSwitch
    disabled
    defaultChecked
/>

Examples

Sizes

The component comes with four size presets:

  • xs: Extra small for compact interfaces
  • sm: Small for dense layouts
  • md: Default medium size
  • lg: Large for prominent switches

Interaction Modes

The switch supports multiple interaction methods:

  • Click to toggle: Single click anywhere on the switch
  • Drag to toggle: Drag the thumb left or right to change state
  • Keyboard accessible: Standard switch keyboard navigation

Glass Effects

The liquid glass effect includes:

  • Dynamic refraction: Light bending based on switch state
  • Realistic blur: Glass-like distortion effects
  • Specular highlights: Glossy surface reflections
  • Smooth transitions: Animated state changes with spring physics
  • LIP surface profile: Realistic glass edge curvature

API Reference

PropTypeDefault
size
"sm" | "md" | "lg" | "xl"
checked
boolean
defaultChecked
boolean
onCheckedChange
function
disabled
boolean
className
string
style
CSSProperties
thumb
WidthHeight
slider
WidthHeight
glassThickness
number
bezelWidth
number
refractiveIndex
number
forceActive
boolean
false
blur
number
specularOpacity
number
specularSaturation
number
refractionBase
number

How is this guide?

Last updated on 12/2/2025