Liquid Glass Lens
Installation
npx shadcn@latest add https://uibrik.com/r/liquid-glass-lens.jsonInstall the packages
npm install motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/liquid-glass-lens/LiquidGlassLens.tsx"use client"; import { cn } from "@/lib/utils"; import { animate, motion, useMotionValue, useTransform } from "motion/react"; import React, { useLayoutEffect, useMemo, useRef, useSyncExternalStore, useId } from "react"; import useMeasure from "react-use-measure"; // A glass pill that refracts whatever sits underneath it. The bending comes // from one SVG filter: feDisplacementMap reads a generated displacement map // whose R and G channels hold the x and y offset for every pixel of the pill. // The map is built on a canvas from the pill's shape, and rebuilt whenever a // dial changes: // // - Bezel: inside a rim band the surface curves away like a convex lens, so // pixels are pulled inward along the edge normal. The profile follows a // circular arc, steep at the very edge and flat by the end of the band. // - Body: the flat middle samples slightly toward the centre, a gentle // magnification. // // The displacement runs three times at slightly different scales and each pass // keeps a single colour channel, so red, green and blue land a pixel or two // apart at the rim — the colour fringe of real glass. The passes are added // back together with screen blends (disjoint channels, so screen is a sum). // // Chromium applies the filter to the page behind the pill via // backdrop-filter: url(#…). Other engines ignore SVG backdrop filters, so they // get a clone of the content inside the pill, offset by the pill's position // and filtered with a regular `filter`. Both paths use the same filter, since // either way user space starts at the pill's top-left corner. The filter // region is given as the bounding box rather than in pixels: WebKit places a // pixel region somewhere other than the element and renders nothing. The clone // is offset with left/top, not a transform, for the same reason. const MAP_DENSITY = 2; const EASE = [0.23, 1, 0.32, 1] as const; const images = [ { src: "https://uibrik.com/images/3.jpg", alt: "Wildflowers against a blue sky" }, { src: "https://uibrik.com/images/2.jpg", alt: "Sunlit clearing in a grove" }, { src: "https://uibrik.com/images/5.jpg", alt: "River between pine forests" }, ]; type Optics = { width: number; height: number; bezel: number; shift: number; zoom: number; }; // Largest offset the map can hold is ±scale / 2, so the scale is sized from // the strongest pull the current optics can produce. const scaleFor = (o: Optics) => 2 * (o.shift + (o.zoom * Math.max(o.width, o.height)) / 2) + 2; function buildDisplacementMap(o: Optics) { const w = Math.round(o.width * MAP_DENSITY); const h = Math.round(o.height * MAP_DENSITY); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d"); if (!ctx) return ""; const data = ctx.createImageData(w, h); const radius = o.height / 2; const bezel = Math.min(o.bezel, radius); const scale = scaleFor(o); const cx = o.width / 2; const cy = o.height / 2; for (let j = 0; j < h; j++) { for (let i = 0; i < w; i++) { const px = (i + 0.5) / MAP_DENSITY; const py = (j + 0.5) / MAP_DENSITY; // Distance from the pill's edge, via the nearest point on its spine. const qx = px - Math.min(Math.max(px, radius), o.width - radius); const qy = py - Math.min(Math.max(py, radius), o.height - radius); const len = Math.hypot(qx, qy); const inset = radius - len; let dx = 0; let dy = 0; if (inset >= 0) { dx = -(px - cx) * o.zoom; dy = -(py - cy) * o.zoom; if (inset < bezel && len > 0) { const t = 1 - inset / bezel; const strength = 1 - Math.sqrt(1 - t * t); dx -= (qx / len) * strength * o.shift; dy -= (qy / len) * strength * o.shift; } } const k = (j * w + i) * 4; data.data[k] = Math.round(128 + (dx / scale) * 255); data.data[k + 1] = Math.round(128 + (dy / scale) * 255); data.data[k + 2] = 128; data.data[k + 3] = 255; } } ctx.putImageData(data, 0, 0); return canvas.toDataURL(); } function isChromium() { const uaData = (navigator as Navigator & { userAgentData?: { brands: { brand: string }[] }; }).userAgentData; return !!uaData?.brands.some((b) => b.brand === "Chromium"); } const noopSubscribe = () => () => {}; const CHANNELS = [ "1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0", "0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0", "0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0", ]; function LensFilter({ id, map, optics, dispersion }: { id: string; map: string; optics: Optics; dispersion: number }) { const scale = scaleFor(optics); return ( <svg aria-hidden className="absolute size-0"> <filter id={id} x="0" y="0" width="1" height="1" filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse" colorInterpolationFilters="sRGB" > <feImage href={map} x="0" y="0" width={optics.width} height={optics.height} preserveAspectRatio="none" result="map" /> {CHANNELS.map((values, i) => ( <React.Fragment key={i}> <feDisplacementMap in="SourceGraphic" in2="map" scale={scale * (1 + dispersion * i)} xChannelSelector="R" yChannelSelector="G" result={`d${i}`} /> <feColorMatrix in={`d${i}`} type="matrix" values={values} result={`c${i}`} /> </React.Fragment> ))} <feBlend in="c0" in2="c1" mode="screen" result="rg" /> <feBlend in="rg" in2="c2" mode="screen" /> </filter> </svg> ); } export function LensSampleContent() { return ( <div className="flex flex-col gap-16 px-6 py-24 sm:px-10"> <header className="flex flex-col gap-4"> <h1 className="text-5xl font-semibold tracking-tight text-zinc-900 sm:text-7xl dark:text-zinc-100"> Light, bent gently. </h1> <p className="max-w-sm text-base leading-relaxed text-zinc-500 dark:text-zinc-400"> Drag the lens across the page. Its rim refracts what sits beneath and splits the colour at the edge. </p> </header> <div className="grid grid-cols-3 gap-3 sm:gap-4"> {images.map((image) => ( <div key={image.src} className="relative aspect-4/5 overflow-hidden rounded-2xl bg-zinc-100 dark:bg-zinc-900" > {/* eslint-disable-next-line @next/next/no-img-element */} <img src={image.src} alt={image.alt} loading="eager" className="absolute inset-0 size-full object-cover" draggable={false} /> </div> ))} </div> </div> ); } export type LiquidGlassLensProps = { /** Lens width in px. */ width?: number; /** Lens height in px. */ height?: number; /** Width of the curved rim that bends light, in px. */ bezel?: number; /** How far the rim pulls pixels inward, in px. */ shift?: number; /** Magnification of the flat middle. */ zoom?: number; /** Colour split at the rim. */ dispersion?: number; /** Frosted blur in px. */ blur?: number; /** White (or dark) tint over the glass, 0 to 1. */ frost?: number; /** Colour boost through the glass. */ saturation?: number; /** Rim highlight strength, 0 to 1. */ highlight?: number; /** Drop shadow strength, 0 to 1. */ shadow?: number; /** What the lens floats over. Defaults to a sample headline and photos. */ children?: React.ReactNode; className?: string; }; /** A draggable glass pill that refracts whatever sits underneath it. */ export function LiquidGlassLens({ width = 224, height = 88, bezel = 38, shift = 18.5, zoom = 0.1, dispersion = 0.065, blur = 2, frost = 0.25, saturation = 1.4, highlight = 0.44, shadow = 0.28, children, className, }: LiquidGlassLensProps) { const filterId = `liquid-glass-${useId().replace(/:/g, "")}`; const content = children ?? <LensSampleContent />; const optics: Optics = { width, height, bezel, shift, zoom }; const mounted = useSyncExternalStore(noopSubscribe, () => true, () => false); const native = useSyncExternalStore(noopSubscribe, isChromium, () => false); const map = useMemo( () => (mounted ? buildDisplacementMap(optics) : ""), // eslint-disable-next-line react-hooks/exhaustive-deps [mounted, optics.width, optics.height, optics.bezel, optics.shift, optics.zoom], ); const stageRef = useRef<HTMLDivElement>(null); const [measureRef, bounds] = useMeasure(); const placed = useRef(false); const x = useMotionValue(0); const y = useMotionValue(0); const cloneX = useTransform(x, (v) => -v); const cloneY = useTransform(y, (v) => -v); // Start the lens over the headline, centred, once the stage has a size. useLayoutEffect(() => { if (placed.current || bounds.width === 0) return; placed.current = true; x.set(Math.round((bounds.width - optics.width) / 2)); y.set(Math.round(148 - optics.height / 2)); }, [bounds.width, optics.width, optics.height, x, y]); const onKeyDown = (e: React.KeyboardEvent) => { const step = e.shiftKey ? 48 : 16; const delta = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step], }[e.key]; if (!delta) return; e.preventDefault(); const clamp = (v: number, max: number) => Math.min(Math.max(v, 0), max); animate(x, clamp(x.get() + delta[0], bounds.width - optics.width), { duration: 0.2, ease: EASE }); animate(y, clamp(y.get() + delta[1], bounds.height - optics.height), { duration: 0.2, ease: EASE }); }; const filter = map ? `url(#${filterId})` : undefined; const material = `blur(${blur}px) saturate(${saturation})`; const glassFilter = filter && `${filter} ${material}`; return ( <div ref={(node) => { stageRef.current = node; measureRef(node); }} className={cn("relative w-full max-w-4xl select-none", className)} > {map && <LensFilter id={filterId} map={map} optics={optics} dispersion={dispersion} />} {content} <motion.div aria-label="Glass lens. Drag, or use the arrow keys to move." tabIndex={0} drag dragConstraints={stageRef} dragElastic={0.06} dragTransition={{ power: 0.2, timeConstant: 180 }} onKeyDown={onKeyDown} style={{ x, y, width: optics.width, height: optics.height, backdropFilter: native ? glassFilter : undefined, boxShadow: `0 1px 2px rgb(0 0 0 / ${shadow * 0.2}), 0 12px 32px -12px rgb(0 0 0 / ${shadow})`, }} className={cn( "absolute top-0 left-0 cursor-grab touch-none overflow-hidden rounded-full outline-none active:cursor-grabbing", "focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-zinc-400", bounds.width === 0 && "opacity-0", )} > {!native && bounds.width > 0 && ( <div aria-hidden inert className="absolute inset-0 overflow-hidden" style={{ filter: glassFilter }}> <motion.div className="absolute bg-background" style={{ left: cloneX, top: cloneY, width: bounds.width }} > {content} </motion.div> </div> )} <div aria-hidden className="pointer-events-none absolute inset-0 rounded-full bg-white dark:bg-zinc-800" style={{ opacity: frost }} /> <div aria-hidden className="pointer-events-none absolute inset-0 rounded-full bg-linear-to-b from-white/30 to-transparent" style={{ opacity: frost > 0 ? 1 : 0 }} /> <div aria-hidden className="pointer-events-none absolute inset-0 rounded-full" style={{ boxShadow: `inset 0 0 0 1px rgb(255 255 255 / ${highlight}), inset 0 1px 1px rgb(255 255 255 / ${Math.min(highlight * 1.3, 1)}), inset 0 -1px 1px rgb(0 0 0 / 0.06)`, }} /> </motion.div> </div> ); }
Adds the files to
components/uibrik/liquid-glass-lens and installs motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/liquid-glass-lens.Props
LiquidGlassLens- width
- Type number
- 224
- Lens width.
- height
- Type number
- 88
- Lens height.
- bezel
- Type number
- 38
- Width of the curved rim that bends light.
- shift
- Type number
- 18.5
- How far the rim pulls pixels inward.
- zoom
- Type number
- 0.1
- Magnification of the flat middle.
- dispersion
- Type number
- 0.065
- Colour split at the rim.
- blur
- Type number
- 2
- Frosted blur.
- frost
- Type number
- 0.25
- Tint over the glass.
- saturation
- Type number
- 1.4
- Colour boost through the glass.
- highlight
- Type number
- 0.44
- Rim highlight strength.
- shadow
- Type number
- 0.28
- Drop shadow strength.
- children
- Type ReactNode
- <LensSampleContent />
- What the lens floats over.
- className
- Type string
- –
- Classes for the stage.