Liquid Switch
A toggle switch component with liquid glass visual effects and smooth animations.
This liquid component is based on the incredible work made by Chris in this blog post : https://kube.io/blog/liquid-glass-css-svg/
This component is experimental, there is still issues that we need to fix to support all different sizes and border radius values.
A 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.
Copy and paste the following code into your project.
'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.
'use client';
import { createCanvas, type ImageData } from 'canvas';
import { motion, MotionValue, useTransform } from 'motion/react';
import { useEffect, useState } from 'react';
import { getDisplacementData } from './liquid-lib';
import { getValueOrMotion } from './liquid-lib';
import { calculateRefractionSpecular } from './liquid-lib';
import { CONVEX } from './liquid-lib';
// function getBezier (bezelType: "convex_circle" | "convex_squircle" | "concave" | "lip") {
// let surfaceFn;
// switch (bezelType) {
// case "convex_circle":
// surfaceFn = CONVEX_CIRCLE.fn;
// break;
// case "convex_squircle":
// surfaceFn = CONVEX.fn;
// break;
// case "concave":
// surfaceFn = CONCAVE.fn;
// break;
// case "lip":
// surfaceFn = LIP.fn;
// break;
// default:
// surfaceFn = CONVEX.fn;
// }
// return surfaceFn;
// }
function imageDataToUrl(imageData: ImageData): string {
const canvas = createCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL();
}
export type LiquidFilterProps = {
id: string;
filterOnly?: boolean;
scaleRatio?: MotionValue<number>;
canvasWidth?: number | MotionValue<number>;
canvasHeight?: number | MotionValue<number>;
width: number | MotionValue<number>;
height: number | MotionValue<number>;
radius: number | MotionValue<number>;
/**
* SVG Gauss gradient applied
* @default 0.2
*/
blur?: number | MotionValue<number>;
/**
* Glass tickess.
* Bigger this value is, longer will be the translations.
* @default 40
*/
glassThickness?: number | MotionValue<number>;
/**
* Width of the non-flat glass surface at the boundaries.
* @default 20
*/
bezelWidth?: number | MotionValue<number>;
/**
* Value used in the snell law: n1 sin(θ1) = n2 sin(θ2)
* Water is 1.33
*
* @default 1.5
*/
refractiveIndex?: number | MotionValue<number>;
/**
* Opacity of the border
* @default 0.4
*/
specularOpacity?: number | MotionValue<number>;
/**
* @default 4
*/
specularSaturation?: number | MotionValue<number>;
dpr?: number | MotionValue<number>;
/**
* Set the profile of the edges.
* @default CONVEX.fn
*/
bezelHeightFn?: (x: number) => number;
// bezelType?: 'convex_circle' | 'convex_squircle' | 'concave' | 'lip';
};
export const LiquidFilter: React.FC<LiquidFilterProps> = ({
id,
filterOnly = false,
canvasWidth,
canvasHeight,
width,
height,
radius,
blur = 0.2,
glassThickness = 40,
bezelWidth: bezelWidthProp = 20,
refractiveIndex = 1.5,
scaleRatio,
specularOpacity = 1,
specularSaturation = 4,
bezelHeightFn = CONVEX.fn,
dpr,
}) => {
// Hydration fix: only render on client to avoid SSR/client mismatch with dynamic canvas data
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const displacementData = useTransform(() => {
const canvasW = canvasWidth ? getValueOrMotion(canvasWidth) : getValueOrMotion(width);
const canvasH = canvasHeight ? getValueOrMotion(canvasHeight) : getValueOrMotion(height);
const devicePixelRatio = dpr ? getValueOrMotion(dpr) : 1;
const clampedBezelWidth = Math.max(Math.min(getValueOrMotion(bezelWidthProp), 2 * getValueOrMotion(radius) - 1), 0);
return getDisplacementData({
glassThickness: getValueOrMotion(glassThickness),
bezelWidth: clampedBezelWidth,
bezelHeightFn,
refractiveIndex: getValueOrMotion(refractiveIndex),
canvasWidth: canvasW,
canvasHeight: canvasH,
objectWidth: getValueOrMotion(width),
objectHeight: getValueOrMotion(height),
radius: getValueOrMotion(radius),
dpr: devicePixelRatio,
});
});
const specularLayer = useTransform(() => {
const devicePixelRatio = dpr ? getValueOrMotion(dpr) : 1;
return calculateRefractionSpecular(
getValueOrMotion(width),
getValueOrMotion(height),
getValueOrMotion(radius),
50,
undefined,
devicePixelRatio
);
});
const displacementMapDataUrl = useTransform(() => {
return imageDataToUrl(displacementData.get().displacementMap);
});
const specularLayerDataUrl = useTransform(() => {
return imageDataToUrl(specularLayer.get());
});
const scale = useTransform(() => displacementData.get().maximumDisplacement * (scaleRatio?.get() ?? 1));
const content = (
<filter id={id}>
<motion.feGaussianBlur
in={'SourceGraphic'}
stdDeviation={typeof blur === 'object' && 'get' in blur ? blur : useTransform(() => blur as number)}
result="blurred_source"
/>
<motion.feImage
href={displacementMapDataUrl}
x={0}
y={0}
width={useTransform(() => (canvasWidth ? getValueOrMotion(canvasWidth) : getValueOrMotion(width)))}
height={useTransform(() => (canvasHeight ? getValueOrMotion(canvasHeight) : getValueOrMotion(height)))}
result="displacement_map"
/>
<motion.feDisplacementMap
in="blurred_source"
in2="displacement_map"
scale={scale}
xChannelSelector="R"
yChannelSelector="G"
result="displaced"
/>
<motion.feColorMatrix
in="displaced"
type="saturate"
values={useTransform(() => getValueOrMotion(specularSaturation).toString()) as any}
result="displaced_saturated"
/>
<motion.feImage
href={specularLayerDataUrl}
x={0}
y={0}
width={useTransform(() => (canvasWidth ? getValueOrMotion(canvasWidth) : getValueOrMotion(width)))}
height={useTransform(() => (canvasHeight ? getValueOrMotion(canvasHeight) : getValueOrMotion(height)))}
result="specular_layer"
/>
<feComposite
in="displaced_saturated"
in2="specular_layer"
operator="in"
result="specular_saturated"
/>
<feComponentTransfer in="specular_layer" result="specular_faded">
<motion.feFuncA
type="linear"
slope={useTransform(() => getValueOrMotion(specularOpacity))}
/>
</feComponentTransfer>
<motion.feBlend in="specular_saturated" in2="displaced" mode="normal" result="withSaturation" />
<motion.feBlend in="specular_faded" in2="withSaturation" mode="normal" />
</filter>
);
// Return null during SSR to prevent hydration mismatch
if (!isMounted) {
return null;
}
return filterOnly ? (
content
) : (
<svg colorInterpolationFilters="sRGB" style={{ display: 'none' }}>
<defs>{content}</defs>
</svg>
);
};
Copy the liquid utilities library.
import { createImageData } from 'canvas';
import { MotionValue } from 'motion';
function calculateRefractionProfile(
glassThickness: number = 200,
bezelWidth: number = 50,
bezelHeightFn: (x: number) => number = (x) => x,
refractiveIndex: number = 1.5,
samples: number = 128
): number[] {
// Pre-calculate the distance the ray will be deviated
// given the distance to border (ratio of bezel)
// and height of the glass
const eta = 1 / refractiveIndex;
// Simplified refraction, which only handles fully vertical incident ray [0, 1]
function refract(normalX: number, normalY: number): [number, number] | null {
const dot = normalY;
const k = 1 - eta * eta * (1 - dot * dot);
if (k < 0) {
// Total internal reflection
return null;
}
const kSqrt = Math.sqrt(k);
return [-(eta * dot + kSqrt) * normalX, eta - (eta * dot + kSqrt) * normalY];
}
return Array.from({ length: samples }, (_, i) => {
const x = i / samples;
const y = bezelHeightFn(x);
// Calculate derivative in x
const dx = x < 1 ? 0.0001 : -0.0001;
const y2 = bezelHeightFn(x + dx);
const derivative = (y2 - y) / dx;
const magnitude = Math.sqrt(derivative * derivative + 1);
const normal: [number, number] = [-derivative / magnitude, -1 / magnitude];
const refracted = refract(normal[0], normal[1]);
if (!refracted) {
return 0;
} else {
const remainingHeightOnBezel = y * bezelWidth;
const remainingHeight = remainingHeightOnBezel + glassThickness;
// Return displacement (rest of travel on x-axis, depends on remaining height to hit bottom of glass)
return refracted[0] * (remainingHeight / refracted[1]);
}
});
}
function generateDisplacementImageData(
canvasWidth: number,
canvasHeight: number,
objectWidth: number,
objectHeight: number,
radius: number,
bezelWidth: number,
maximumDisplacement: number,
refractionProfile: number[] = [],
dpr?: number
) {
const devicePixelRatio = dpr ?? (typeof window !== 'undefined' ? (window.devicePixelRatio ?? 1) : 1);
const bufferWidth = canvasWidth * devicePixelRatio;
const bufferHeight = canvasHeight * devicePixelRatio;
// console.log( {bufferWidth, bufferHeight} )
const imageData = createImageData(bufferWidth, bufferHeight);
// Fill neutral color using buffer
const neutral = 0xff008080;
new Uint32Array(imageData.data.buffer).fill(neutral);
const radius_ = radius * devicePixelRatio;
const bezel = bezelWidth * devicePixelRatio;
const radiusSquared = radius_ ** 2;
const radiusPlusOneSquared = (radius_ + 1) ** 2;
const radiusMinusBezelSquared = (radius_ - bezel) ** 2;
const objectWidth_ = objectWidth * devicePixelRatio;
const objectHeight_ = objectHeight * devicePixelRatio;
const widthBetweenRadiuses = objectWidth_ - radius_ * 2;
const heightBetweenRadiuses = objectHeight_ - radius_ * 2;
const objectX = (bufferWidth - objectWidth_) / 2;
const objectY = (bufferHeight - objectHeight_) / 2;
for (let y1 = 0; y1 < objectHeight_; y1++) {
for (let x1 = 0; x1 < objectWidth_; x1++) {
const idx = ((objectY + y1) * bufferWidth + objectX + x1) * 4;
const isOnLeftSide = x1 < radius_;
const isOnRightSide = x1 >= objectWidth_ - radius_;
const isOnTopSide = y1 < radius_;
const isOnBottomSide = y1 >= objectHeight_ - radius_;
const x = isOnLeftSide ? x1 - radius_ : isOnRightSide ? x1 - radius_ - widthBetweenRadiuses : 0;
const y = isOnTopSide ? y1 - radius_ : isOnBottomSide ? y1 - radius_ - heightBetweenRadiuses : 0;
const distanceToCenterSquared = x * x + y * y;
const isInBezel =
distanceToCenterSquared <= radiusPlusOneSquared &&
distanceToCenterSquared >= radiusMinusBezelSquared;
// Only write non-neutral displacements (when isInBezel)
if (isInBezel) {
const opacity =
distanceToCenterSquared < radiusSquared
? 1
: 1 -
(Math.sqrt(distanceToCenterSquared) - Math.sqrt(radiusSquared)) /
(Math.sqrt(radiusPlusOneSquared) - Math.sqrt(radiusSquared));
const distanceFromCenter = Math.sqrt(distanceToCenterSquared);
const distanceFromSide = radius_ - distanceFromCenter;
// Viewed from top
const cos = x / distanceFromCenter;
const sin = y / distanceFromCenter;
const bezelIndex = ((distanceFromSide / bezel) * refractionProfile.length) | 0;
const distance = refractionProfile[bezelIndex] ?? 0;
const dX = (-cos * distance) / maximumDisplacement;
const dY = (-sin * distance) / maximumDisplacement;
imageData.data[idx] = 128 + dX * 127 * opacity; // R
imageData.data[idx + 1] = 128 + dY * 127 * opacity; // G
imageData.data[idx + 2] = 0; // B
imageData.data[idx + 3] = 255; // A
}
}
}
return imageData;
}
export const getDisplacementData = ({
glassThickness = 200,
bezelWidth = 50,
bezelHeightFn = (x) => x,
refractiveIndex = 1.5,
samples = 128,
canvasWidth,
canvasHeight,
objectWidth,
objectHeight,
radius,
dpr,
}: {
glassThickness?: number;
bezelWidth?: number;
bezelHeightFn?: (x: number) => number;
refractiveIndex?: number;
samples?: number;
canvasWidth: number;
canvasHeight: number;
objectWidth: number;
objectHeight: number;
radius: number;
dpr?: number;
}) => {
const refractionProfile = calculateRefractionProfile(
glassThickness,
bezelWidth,
bezelHeightFn,
refractiveIndex,
samples
);
const maximumDisplacement = Math.max(...refractionProfile.map((v) => Math.abs(v)));
const displacementMap = generateDisplacementImageData(
canvasWidth,
canvasHeight,
objectWidth,
objectHeight,
radius,
bezelWidth,
maximumDisplacement,
refractionProfile,
dpr
);
return {
displacementMap,
maximumDisplacement,
};
};
export function calculateRefractionSpecular(
objectWidth: number,
objectHeight: number,
radius: number,
bezelWidth: number,
specularAngle = Math.PI / 3,
dpr?: number
) {
const devicePixelRatio = dpr ?? (typeof window !== 'undefined' ? (window.devicePixelRatio ?? 1) : 1);
const bufferWidth = objectWidth * devicePixelRatio;
const bufferHeight = objectHeight * devicePixelRatio;
const imageData = createImageData(bufferWidth, bufferHeight);
const radius_ = radius * devicePixelRatio;
const bezel_ = bezelWidth * devicePixelRatio;
// Vector along which we should see specular
const specular_vector = [Math.cos(specularAngle), Math.sin(specularAngle)];
// Fill neutral color using buffer
const neutral = 0x00000000;
new Uint32Array(imageData.data.buffer).fill(neutral);
const radiusSquared = radius_ ** 2;
const radiusPlusOneSquared = (radius_ + devicePixelRatio) ** 2;
const radiusMinusBezelSquared = (radius_ - bezel_) ** 2;
const widthBetweenRadiuses = bufferWidth - radius_ * 2;
const heightBetweenRadiuses = bufferHeight - radius_ * 2;
for (let y1 = 0; y1 < bufferHeight; y1++) {
for (let x1 = 0; x1 < bufferWidth; x1++) {
const idx = (y1 * bufferWidth + x1) * 4;
const isOnLeftSide = x1 < radius_;
const isOnRightSide = x1 >= bufferWidth - radius_;
const isOnTopSide = y1 < radius_;
const isOnBottomSide = y1 >= bufferHeight - radius_;
const x = isOnLeftSide ? x1 - radius_ : isOnRightSide ? x1 - radius_ - widthBetweenRadiuses : 0;
const y = isOnTopSide ? y1 - radius_ : isOnBottomSide ? y1 - radius_ - heightBetweenRadiuses : 0;
const distanceToCenterSquared = x * x + y * y;
const isInBezel =
distanceToCenterSquared <= radiusPlusOneSquared &&
distanceToCenterSquared >= radiusMinusBezelSquared;
// Process pixels that are in bezel or near bezel edge for anti-aliasing
if (isInBezel) {
const distanceFromCenter = Math.sqrt(distanceToCenterSquared);
const distanceFromSide = radius_ - distanceFromCenter;
const opacity =
distanceToCenterSquared < radiusSquared
? 1
: 1 -
(distanceFromCenter - Math.sqrt(radiusSquared)) /
(Math.sqrt(radiusPlusOneSquared) - Math.sqrt(radiusSquared));
// Viewed from top
const cos = x / distanceFromCenter;
const sin = -y / distanceFromCenter;
// Dot product of orientation
const dotProduct = Math.abs(cos * specular_vector[0]! + sin * specular_vector[1]!);
const coefficient =
dotProduct * Math.sqrt(1 - (1 - distanceFromSide / (1 * devicePixelRatio)) ** 2);
const color = 255 * coefficient;
const finalOpacity = color * coefficient * opacity;
imageData.data[idx] = color;
imageData.data[idx + 1] = color;
imageData.data[idx + 2] = color;
imageData.data[idx + 3] = finalOpacity;
}
}
}
return imageData;
}
export function getValueOrMotion<T>(value: T | MotionValue<T>): T {
return value instanceof MotionValue ? value.get() : value;
}
// equations
export type SurfaceFnDef = {
title: string;
fn: (x: number) => number;
};
export const CONVEX_CIRCLE: SurfaceFnDef = {
title: 'Convex Circle',
fn: (x) => Math.sqrt(1 - (1 - x) ** 2),
};
export const CONVEX: SurfaceFnDef = {
title: 'Convex Squircle',
fn: (x) => Math.pow(1 - Math.pow(1 - x, 4), 1 / 4),
};
export const CONCAVE: SurfaceFnDef = {
title: 'Concave',
fn: (x) => 1 - CONVEX_CIRCLE.fn(x),
};
export const LIP: SurfaceFnDef = {
title: 'Lip',
fn: (x) => {
const convex = CONVEX.fn(x * 2);
const concave = CONCAVE.fn(x) + 0.1;
const smootherstep = 6 * x ** 5 - 15 * x ** 4 + 10 * x ** 3;
return convex * (1 - smootherstep) + concave * smootherstep;
},
};
export const WAVE: SurfaceFnDef = {
title: 'Wave',
fn: (x) => {
const base = Math.pow(x, 0.5);
const wave = Math.sin(x * Math.PI * 3) * 0.1;
return Math.max(0, Math.min(1, base + wave));
},
};
export const STEPPED: SurfaceFnDef = {
title: 'Stepped',
fn: (x) => {
const steps = 4;
const stepSize = 1 / steps;
const stepIndex = Math.floor(x / stepSize);
const stepProgress = (x % stepSize) / stepSize;
const stepHeight = stepIndex / (steps - 1);
const smoothing = Math.pow(stepProgress, 3) * (stepProgress * (stepProgress * 6 - 15) + 10);
return stepHeight + smoothing * (1 / (steps - 1));
},
};
export const ELASTIC: SurfaceFnDef = {
title: 'Elastic',
fn: (x) => {
if (x === 0) return 0;
if (x === 1) return 1;
const p = 0.3;
const s = p / 4;
return Math.pow(2, -10 * x) * Math.sin(((x - s) * (2 * Math.PI)) / p) + 1;
},
};
export const BUBBLE: SurfaceFnDef = {
title: 'Bubble',
fn: (x) => {
const center = 0.6;
const width = 0.4;
const height = 1.2;
const distance = Math.abs(x - center) / width;
if (distance > 1) return 0;
const bubble = Math.sqrt(1 - distance * distance) * height;
const base = Math.pow(x, 2);
return Math.max(0, Math.min(1, Math.max(base, bubble)));
},
};
export const fns: SurfaceFnDef[] = [CONVEX_CIRCLE, CONVEX, CONCAVE, LIP, WAVE, STEPPED, ELASTIC, BUBBLE];
Update the import paths to match your project setup.
Usage
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 interfacessm: Small for dense layoutsmd: Default medium sizelg: 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
| Prop | Type | Default |
|---|---|---|
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 |
A liquid glass-effect slider component with smooth animations and customizable appearance.
An animated mockup to switch and render macOS, iPhone and Android devices.
How is this guide?
Last updated on 12/2/2025