Crystal-clear glass cards with lens-like edges, rainbow dispersion, a rim highlight that tracks the pointer and a liquid press.
Now playing
Glass, light and depth
Customize
Usage
import { LiquidGlassCard } from "@/components/motionkit/liquid-glass-card";
{/* clear glass shows what is behind it: place it over an image, pattern or background effect */}
<LiquidGlassCard className="w-72">
<h3>Glass, light and depth</h3>
<p>Hover me.</p>
</LiquidGlassCard>Motion
The exact curves this component runs, plotted with Motion's own maths.
Lift and shadow
Pointer enters the card
Content parallax
Pointer moves over the card
Light sweep
Pointer enters the card
Rim highlight
Pointer moves over the card
Press squish
Card pressed
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/liquid-glass-card.tsx.
"use client";
import { Fragment, useEffect, useId, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
type LiquidGlassCardProps = {
children?: ReactNode;
/** 0–1. How strongly the rim bends what is behind it. Chromium only; other browsers show clear glass. */
refraction?: number;
/** 0–1. Rainbow fringing where the rim bends light, like a real lens. Needs refraction. */
dispersion?: number;
/** Backdrop blur in pixels. 0 keeps the glass perfectly clear. */
blur?: number;
/** How far the content drifts toward the pointer, in pixels. Flat, not a 3D tilt: Chrome drops the refraction on 3D-transformed elements. */
parallax?: number;
/** Colour of the luminous border and reflection glow. */
glow?: string;
className?: string;
};
const RADIUS = 24;
// shows only a thin ring: the content box is cut out of the full box
const ring = (width: number): CSSProperties => ({
padding: width,
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
maskComposite: "exclude",
});
// where the rim highlight rests (top left) when no pointer is over the card, in conic degrees
const REST_LIGHT = 315;
// colour matrices that keep only red, green or blue, so each can be bent by a slightly different amount
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",
];
/**
* A displacement map the size of the card, worked out per pixel: near the rim each point is pushed
* along the edge's outward direction, easing off like the curve of a lens; the middle stays put.
* Red holds the x push and green the y push, with 128 meaning none.
*/
function displacementMap(width: number, height: number) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return null;
const image = context.createImageData(width, height);
const edge = Math.min(width, height) * 0.18;
const halfX = width / 2 - RADIUS;
const halfY = height / 2 - RADIUS;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
// signed distance to a rounded rectangle, and the outward normal at the nearest edge
const px = col + 0.5 - width / 2;
const py = row + 0.5 - height / 2;
const qx = Math.abs(px) - halfX;
const qy = Math.abs(py) - halfY;
let nx = 0;
let ny = 0;
let depth: number;
if (qx > 0 && qy > 0) {
const length = Math.hypot(qx, qy);
depth = RADIUS - length;
nx = (qx / length) * Math.sign(px);
ny = (qy / length) * Math.sign(py);
} else if (qx > qy) {
depth = RADIUS - qx;
nx = Math.sign(px);
} else {
depth = RADIUS - qy;
ny = Math.sign(py);
}
// 0 in the middle, rising to 1 at the rim along a circular (lens) curve
const t = Math.min(Math.max(1 - depth / edge, 0), 1);
const strength = 1 - Math.sqrt(1 - t * t);
const index = (row * width + col) * 4;
image.data[index] = 128 + nx * strength * 127;
image.data[index + 1] = 128 + ny * strength * 127;
image.data[index + 2] = 128;
image.data[index + 3] = 255;
}
}
context.putImageData(image, 0, 0);
return canvas.toDataURL();
}
export function LiquidGlassCard({
children,
refraction = 0.5,
dispersion = 0.4,
blur = 0,
parallax = 4,
glow = "#5FAEB4",
className = "",
}: LiquidGlassCardProps) {
const reduceMotion = useReducedMotion();
const card = useRef<HTMLDivElement>(null);
const [lensMap, setLensMap] = useState<{ width: number; height: number; href: string } | null>(null);
const filterId = `glass-${useId().replace(/[^\w-]/g, "")}`;
// pointer position across the card, 0–1 on each axis
const px = useMotionValue(0.5);
const py = useMotionValue(0.5);
const spring = { stiffness: 180, damping: 22 };
const contentX = useSpring(useTransform(px, [0, 1], [-parallax, parallax]), spring);
const contentY = useSpring(useTransform(py, [0, 1], [-parallax, parallax]), spring);
const x = useTransform(px, (v) => v * 100);
const y = useTransform(py, (v) => v * 100);
const reflection = useMotionTemplate`radial-gradient(circle at ${x}% ${y}%, rgb(255 255 255 / 0.16), transparent 45%)`;
const borderGlow = useMotionTemplate`radial-gradient(220px circle at ${x}% ${y}%, ${glow}, transparent 70%)`;
// the rim highlight turns to face the pointer, with a fainter echo on the opposite side
const light = useMotionValue(REST_LIGHT);
const smoothLight = useSpring(light, { stiffness: 120, damping: 20 });
const specular = useMotionTemplate`conic-gradient(from ${useTransform(smoothLight, (a) => a - 70)}deg, transparent, rgb(255 255 255 / 0.95) 70deg, transparent 140deg, transparent 180deg, rgb(255 255 255 / 0.4) 250deg, transparent 320deg)`;
// the displacement map is drawn in pixels, so it is redrawn whenever the card changes size
useEffect(() => {
const element = card.current;
if (!element || refraction <= 0) return;
const observer = new ResizeObserver(() => {
const width = Math.round(element.offsetWidth);
const height = Math.round(element.offsetHeight);
const href = width && height ? displacementMap(width, height) : null;
setLensMap(href ? { width, height, href } : null);
});
observer.observe(element);
return () => observer.disconnect();
}, [refraction]);
/** Moves the highlight the short way round, so it never spins a full turn crossing 0°. */
function aimLight(target: number) {
const current = light.get();
light.set(current + ((((target - current) % 360) + 540) % 360) - 180);
}
function handleMove(event: PointerEvent<HTMLDivElement>) {
if (reduceMotion) return;
const rect = event.currentTarget.getBoundingClientRect();
const nx = (event.clientX - rect.left) / rect.width;
const ny = (event.clientY - rect.top) / rect.height;
px.set(nx);
py.set(ny);
// screen angle from the centre, turned so 0° points up like a conic gradient
aimLight((Math.atan2(ny - 0.5, nx - 0.5) * 180) / Math.PI + 90);
}
function handleLeave() {
px.set(0.5);
py.set(0.5);
aimLight(REST_LIGHT);
}
const lens = refraction > 0 && lensMap;
// browsers without SVG backdrop filters drop the whole value, so the url() goes in its own layer
const clearFilter = blur ? `blur(${blur}px) saturate(140%)` : "saturate(140%) brightness(1.06)";
return (
<div className={className}>
<motion.div
ref={card}
onPointerMove={handleMove}
onPointerLeave={handleLeave}
initial="rest"
animate="rest"
whileHover="hover"
whileTap={reduceMotion ? undefined : "press"}
variants={{
rest: { y: 0, scale: 1, boxShadow: "0 12px 32px -14px rgb(0 0 0 / 0.5)" },
hover: { y: reduceMotion ? 0 : -6, scale: 1, boxShadow: "0 28px 60px -18px rgb(0 0 0 / 0.65)" },
// a soft squish, like pressing into something liquid
press: { y: -2, scale: 0.975, boxShadow: "0 16px 36px -16px rgb(0 0 0 / 0.6)" },
}}
transition={{ type: "spring", bounce: 0.2, duration: 0.5 }}
style={{ borderRadius: RADIUS }}
className="group relative h-full overflow-hidden text-[#F6F1E8]"
>
{lens && (
<svg aria-hidden className="absolute size-0">
<filter id={filterId} x="0" y="0" width="100%" height="100%" colorInterpolationFilters="sRGB">
<feImage href={lensMap.href} x="0" y="0" width={lensMap.width} height={lensMap.height} result="map" />
{/* red bends most and blue least, splitting light into a thin fringe at the rim.
Fragments, not <g>: filter primitives must be direct children of <filter>. */}
{channels.map((matrix, index) => (
<Fragment key={index}>
<feDisplacementMap
in="SourceGraphic"
in2="map"
scale={refraction * 70 * (1 + dispersion * 0.18 * (1 - index))}
xChannelSelector="R"
yChannelSelector="G"
result={`bent${index}`}
/>
<feColorMatrix in={`bent${index}`} type="matrix" values={matrix} result={`channel${index}`} />
</Fragment>
))}
<feBlend in="channel0" in2="channel1" mode="screen" result="redGreen" />
<feBlend in="redGreen" in2="channel2" mode="screen" />
</filter>
</svg>
)}
{/* glass layers sit side by side, not nested: a nested backdrop-filter would only see its parent.
Each repeats the radius because Chrome doesn't clip backdrop-filter to a rounded ancestor. */}
<div
aria-hidden
className="absolute inset-0 rounded-[inherit]"
style={{ backdropFilter: clearFilter, WebkitBackdropFilter: clearFilter }}
/>
{/* the rim bends the background like the edge of a lens; the middle stays sharp */}
{lens && (
<div aria-hidden className="absolute inset-0 rounded-[inherit]" style={{ backdropFilter: `url(#${filterId})` }} />
)}
{/* thickness: a bright top lip, a brighter inner rim, and the lower edge shaded where the glass is deepest */}
<div
aria-hidden
className="absolute inset-0 rounded-[inherit]"
style={{
boxShadow:
"inset 0 1.5px 1px rgb(255 255 255 / 0.5), inset 0 -1px 1px rgb(255 255 255 / 0.15), inset 0 0 6px 1px rgb(255 255 255 / 0.1), inset 0 0 22px rgb(255 255 255 / 0.06), inset 0 -14px 24px -16px rgb(0 0 0 / 0.35)",
}}
/>
{/* a fixed glint across the top-left, like light caught on a curved surface */}
<div
aria-hidden
className="absolute inset-0 rounded-[inherit]"
style={{ background: "linear-gradient(150deg, rgb(255 255 255 / 0.14), transparent 30%, transparent 75%, rgb(255 255 255 / 0.05))" }}
/>
{/* soft reflection under the pointer */}
<motion.div
aria-hidden
style={{ background: reflection }}
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-focus-within:opacity-100 group-hover:opacity-100"
/>
{/* a band of light that glides across on hover */}
<motion.div
aria-hidden
className="absolute inset-y-0 -left-1/2 w-1/2 skew-x-[-20deg] bg-gradient-to-r from-transparent via-white/[0.1] to-transparent"
variants={{ rest: { x: "0%" }, hover: { x: reduceMotion ? "0%" : "420%" } }}
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
/>
{/* luminous border: a quiet white edge, a specular arc that turns toward the pointer,
and colour that gathers near it */}
<div aria-hidden className="absolute inset-0 rounded-[inherit]" style={{ ...ring(1), background: "rgb(255 255 255 / 0.16)" }} />
<motion.div aria-hidden className="absolute inset-0 rounded-[inherit]" style={{ ...ring(1.5), background: specular }} />
<motion.div
aria-hidden
className="absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-300 group-focus-within:opacity-100 group-hover:opacity-100"
style={{ ...ring(1), background: borderGlow }}
/>
{/* a faint shadow under the text keeps it readable over whatever shows through */}
<motion.div
style={reduceMotion ? undefined : { x: contentX, y: contentY }}
className="relative p-6 [text-shadow:0_1px_2px_rgb(0_0_0/0.35)]"
>
{children}
</motion.div>
</motion.div>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| refraction | number | 0.5 | 0–1. How strongly the rim bends what is behind it. Chrome and Edge only; other browsers show clear glass. |
| dispersion | number | 0.4 | 0–1. Rainbow fringing where the rim bends light, like a real lens. Needs refraction. |
| blur | number | 0 | Backdrop blur in pixels. 0 keeps the glass perfectly clear. |
| parallax | number | 4 | How far the content drifts toward the pointer, in pixels. 0 keeps it still. |
| glow | string | "#5FAEB4" | Colour of the luminous border and reflection glow. |
| children | ReactNode | — | Card content. |
Realtime sync
Move your cursor across this card.
Spotlight Card
A card with a soft glow that tracks the pointer across its surface.
Edge functions
Tilt me with your cursor.
Tilt Card
A 3D card that tilts toward the pointer, with an optional moving glare.
Launch kit
Tilt me to see the layers.
Depth Card
A card that tilts toward the cursor while its layers float at different depths, so each moves at its own speed.