Liquid Slider
A liquid glass-effect slider component with smooth animations and customizable appearance.
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 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.
Copy and paste the following code into your project.
'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.
'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 { 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 interfacessm: Small size for secondary controlsmd: Default medium sizelg: 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
| Prop | Type | Default |
|---|---|---|
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 |
Simulate light refraction respecting the Snell laws. Same effect used in iOS 26.
A toggle switch component with liquid glass visual effects and smooth animations.
How is this guide?
Last updated on 12/2/2025