液态玻璃按钮
Liquid Glass Button半透明、有厚度、会回弹的玻璃消息按钮
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
指针交互按住按钮 · 松手看果冻回弹
点击更换按钮底下的底纹
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4 + Motion v14(motion 包)。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现一个浮在花纹壁纸上的液态玻璃消息按钮:10px 的背板模糊与 0.13 的白色着色让花纹透过玻璃仍然可见,1px 高光边包住按钮,下方 9px 的玻璃厚边提供真实的厚度。按下时它被压向厚边,松手后过冲回弹一次,像果冻一样(回弹 0.6)。
文件契约
- 只新建一个文件:components/effects/LiquidGlassButton.tsx。
- 依赖:项目已安装 motion@14,从 motion/react 引入。不要新增任何其他包。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client"。
- 用 useMotionValue(0) + animate()(来自 motion/react)驱动按下量,按下的弹簧与松手的弹簧参数不同,松手时保留过冲。
- 用 useTransform 从按下量派生 scaleX、scaleY 和玻璃面层的 y;指针位置只写 CSS 变量 --mx / --my,不要走 React state。
- 玻璃面层用行内样式写 backdropFilter 与 WebkitBackdropFilter(模糊值来自 props),并加 overflow-hidden + border-radius: inherit,否则模糊会溢出圆角。
- 导出 LiquidGlassButton,props 为 blur、tint、thickness、bounce、highlight、title、subtitle、onClick、className。
- 用 useReducedMotion() 降级:不做形变与高光跟随,按钮仍可点击、仍有焦点环。
规格 —— 精确数值,不许四舍五入或“优化”
- 按钮:292px × 64px(最大宽度 100%),圆角 22px,内边距 0 14px,背景透明,按钮元素本身不加边框。
- 玻璃面层:绝对定位 inset 0,继承圆角,overflow: hidden,边框 1px 实线 rgba(255, 255, 255, 0.42);backdrop-filter: blur(10px) saturate(1.7) brightness(0.97),同时写 -webkit-backdrop-filter。
- 模糊层之上的着色:linear-gradient(155deg, rgba(255,255,255,0.13) 0%, rgba(255,255,255,0.059) 52%, rgba(255,255,255,0.098) 100%)。面层必须保持半透明,底下的花纹要能辨认。
- 内侧高光:inset 0 1px 0 rgba(255, 255, 255, 0.62), inset 0 -1px 1px rgba(255,255,255,0.22), inset 0 -12px 20px -14px rgba(255,255,255,0.35)。
- 厚度边:面层后面再加一层——左右各 3px,top 100%,高 15px,margin-top -6px,圆角 0 0 22px 22px;背景 linear-gradient(180deg, rgba(255,255,255,0.34) 0%, rgba(255,255,255,0.10) 32%, rgba(9,16,28,0.55) 100%);1px 实线 rgba(255,255,255,0.18);backdrop-filter: blur(10px) saturate(1.4) brightness(0.8);box-shadow: 0 14px 26px rgba(0,0,0,0.26)。
- 顶部高光:inset 0 的绝对定位层,linear-gradient(180deg, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.08) 34%, rgba(255,255,255,0) 58%),透明度 0.6,被面层裁切。
- 按下形变:按钮 scaleX = 1 + 0.05p、scaleY = 1 - 0.14p,transform-origin 50% 70%;玻璃面层和内容向下位移 0.55 × 9 × p px,厚度边保持不动,所以可见厚度被压薄。p 是 0→1 的按下量。
- 弹簧:按下用刚度 640、阻尼 34、质量 0.7;松手用刚度 460、阻尼 22.8、质量 0.7。松手必须过冲一次再回到静止。
- 悬停:整个按钮上浮 1.5px、缩放到 1.015;悬停过程中不产生回弹。
- 指针高光:radial-gradient(150px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.55), transparent 62%),mix-blend-mode: screen,静止时透明度 0,指针进入后最高 0.9。在按钮上写 --mx / --my 百分比来跟随指针。
- 内容:图标块 38px 见方、圆角 12px、1px 实线 rgba(255,255,255,0.35)、半透明白渐变;右上角一个 8px 的 #3DDC84 未读点(2px 半透明白色描边);标题 13.5px / 600 / 1.15,字距 -0.01em,白色;副标题 10.5px,颜色 rgba(255, 255, 255, 0.78);两者文字阴影 0 1px 3px rgba(8,10,18,0.45);右侧 14px 箭头,70% 白。
- 只允许绘制 transform、opacity 与 backdrop-filter;组件里任何一层都不能不透明到遮住壁纸。
行为
- 组件本身就应该是透明的:它直接放在宿主页面背景上,并让背景透过玻璃被看见。
- pointerdown(仅主键):按下弹簧朝 1 走。pointerup、pointercancel、pointerleave 都只释放一次回到 0;重复释放不能堆积动画。
- 键盘:空格 keydown 按下、keyup 释放,不要阻止原生 click;Enter 走原生 click;必须是真实的 <button type="button">,并保留可见的 focus-visible 描边。
- 触屏:同样的压扁与回弹;高光跟随触点,但不做悬停上浮。
- 指针在按钮内移动时更新 --mx / --my 与高光;离开时高光淡出、上浮归零。
- prefers-reduced-motion: reduce:不压扁、不上浮、不追踪高光;按钮保持静止但仍能触发 click。
- 卸载时清理动画与监听(不要有 document / window 级监听);不要在卸载后更新状态。
禁止
- 不要在按钮或玻璃面层上写不透明背景、实色渐变终点或 background: white。
- 不要用 canvas、SVG 滤镜或复制一张壁纸来伪造模糊;必须用 backdrop-filter。
- 不要动画 width / height / margin / padding / top / left;厚度变化只能来自面层的位移。
- 不要在组件外面额外做壁纸、底纹选择器、演示壳或页面。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要改动全局 CSS、Tailwind 配置或上面文件之外的任何文件。
- 除 Motion 外不要引入任何动画库,更不要用 GSAP。
验收 —— 回答前逐项自查
1. 透过玻璃能清楚看到底纹:切换四款底纹时,按钮内部的颜色与纹理随之改变,没有不透明填充。
2. 圆角外没有模糊溢出或光晕;1px 高光边完整包住玻璃面。
3. 按钮下方有一条可见的玻璃厚边;按下时它被压薄,松手后恢复原厚度。
4. 按下是快速的压扁,松手只过冲一次并在约 0.6s 内稳定,没有持续抖动。
5. 文字的对比度在四款底纹上都足够;按钮在触屏、鼠标和键盘下都可点击。
6. prefers-reduced-motion 下不播放形变与高光,按钮仍保留焦点环并可触发 click。
7. 零控制台报错;卸载后监听与动画都被清理;只改变 transform / opacity / 背板滤镜。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import {
useEffect,
useRef,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { GLASS as G } from "@/content/effects/liquid-glass-button/constants";
export type LiquidGlassButtonProps = {
/** label on the first line */
title?: string;
/** secondary line under the title */
subtitle?: string;
/** backdrop blur in px */
blur?: number;
/** white tint alpha, 0–1 */
tint?: number;
/** visible thickness of the bottom glass edge, in px */
thickness?: number;
/** 0–1, lowers the release damping for a bigger jelly overshoot */
bounce?: number;
/** pointer-following specular sheen */
highlight?: boolean;
/** optional custom message icon */
icon?: ReactNode;
onClick?: () => void;
className?: string;
};
const clamp01 = (value: number) => Math.min(Math.max(value, 0), 1);
export function LiquidGlassButton({
title = "查看消息",
subtitle = "3 条新消息",
blur = G.defaults.blur,
tint = G.defaults.tint,
thickness = G.defaults.thickness,
bounce = G.defaults.bounce,
highlight = G.defaults.highlight,
icon,
onClick,
className,
}: LiquidGlassButtonProps) {
const reduced = useReducedMotion();
const buttonRef = useRef<HTMLButtonElement>(null);
const pressedRef = useRef(false);
const animationRef = useRef<ReturnType<typeof animate> | null>(null);
const press = useMotionValue(0);
const hover = useSpring(0, {
stiffness: G.spring.hoverStiffness,
damping: G.spring.hoverDamping,
mass: G.spring.mass,
});
const safeThickness = Math.max(0, thickness);
const safeBounce = clamp01(bounce);
const releaseDamping =
G.spring.releaseDampingBase - safeBounce * G.spring.releaseDampingBounce;
const scaleX = useTransform([press, hover], ([p, h]: number[]) =>
reduced ? 1 : (1 + p * G.squash.x) * (1 + h * (G.hover.scale - 1)),
);
const scaleY = useTransform([press, hover], ([p, h]: number[]) =>
reduced ? 1 : (1 - p * G.squash.y) * (1 + h * (G.hover.scale - 1)),
);
const lift = useTransform(hover, (h) => (reduced ? 0 : h * -G.hover.lift));
const faceY = useTransform(press, (p) =>
reduced ? 0 : p * safeThickness * G.squash.shift,
);
const sheenOpacity = useTransform(hover, (h) =>
highlight && !reduced ? h * G.sheen.opacity : 0,
);
useEffect(() => {
return () => {
animationRef.current?.stop();
};
}, []);
const startPress = () => {
if (pressedRef.current) return;
pressedRef.current = true;
if (reduced) return;
animationRef.current?.stop();
animationRef.current = animate(press, 1, {
type: "spring",
stiffness: G.spring.pressStiffness,
damping: G.spring.pressDamping,
mass: G.spring.mass,
});
};
const releasePress = () => {
if (!pressedRef.current) return;
pressedRef.current = false;
if (reduced) {
press.set(0);
return;
}
animationRef.current?.stop();
animationRef.current = animate(press, 0, {
type: "spring",
stiffness: G.spring.releaseStiffness,
damping: releaseDamping,
mass: G.spring.mass,
});
};
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
startPress();
};
const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
if (reduced) return;
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
buttonRef.current?.style.setProperty("--mx", `${x}%`);
buttonRef.current?.style.setProperty("--my", `${y}%`);
};
const handlePointerEnter = (event: PointerEvent<HTMLButtonElement>) => {
if (reduced) return;
handlePointerMove(event);
if (event.pointerType === "touch") return;
hover.set(1);
};
const handlePointerLeave = () => {
releasePress();
hover.set(0);
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== " " && event.key !== "Enter") return;
if (event.repeat) return;
startPress();
};
const handleKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== " " && event.key !== "Enter") return;
releasePress();
};
const faceBackground = `linear-gradient(155deg, rgba(255,255,255,${tint}) 0%, rgba(255,255,255,${tint * 0.45}) 52%, rgba(255,255,255,${tint * 0.75}) 100%)`;
const backdrop = `blur(${blur}px) saturate(${G.backdrop.saturate}) brightness(${G.backdrop.brightness})`;
const edgeHeight = G.edge.visible + safeThickness;
return (
<motion.button
ref={buttonRef}
type="button"
data-demo="liquid-glass-button"
aria-label={`${title} — ${subtitle}`}
onClick={onClick}
onPointerDown={handlePointerDown}
onPointerUp={releasePress}
onPointerCancel={releasePress}
onPointerEnter={handlePointerEnter}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={() => {
releasePress();
hover.set(0);
}}
className={[
"relative inline-flex select-none items-center justify-center text-left",
"outline-none focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-white",
className ?? "",
].join(" ")}
style={{
width: G.button.width,
maxWidth: "100%",
height: G.button.height,
borderRadius: G.button.radius,
background: "transparent",
border: 0,
padding: 0,
isolation: "isolate",
scaleX,
scaleY,
y: lift,
transformOrigin: "50% 70%",
touchAction: "manipulation",
}}
>
{/* The thickness edge stays put while the glass face sinks into it. */}
<span
aria-hidden
style={{
position: "absolute",
left: G.edge.inset,
right: G.edge.inset,
top: "100%",
height: edgeHeight,
marginTop: -G.edge.visible,
zIndex: 0,
borderRadius: `0 0 ${G.button.radius}px ${G.button.radius}px`,
border: "1px solid rgba(255, 255, 255, 0.18)",
background:
"linear-gradient(180deg, rgba(255,255,255,0.34) 0%, rgba(255,255,255,0.10) 32%, rgba(9,16,28,0.55) 100%)",
backdropFilter: "blur(10px) saturate(1.4) brightness(0.8)",
WebkitBackdropFilter: "blur(10px) saturate(1.4) brightness(0.8)",
boxShadow: "0 14px 26px rgba(0, 0, 0, 0.26)",
}}
/>
<motion.span
style={{
position: "absolute",
inset: 0,
zIndex: 1,
display: "flex",
alignItems: "center",
width: "100%",
borderRadius: "inherit",
overflow: "hidden",
border: `${G.rim.width}px solid ${G.rim.color}`,
background: faceBackground,
padding: `0 ${G.button.paddingX}px`,
y: faceY,
backdropFilter: backdrop,
WebkitBackdropFilter: backdrop,
boxShadow: `inset 0 1px 0 ${G.rim.highlight}, inset 0 -1px 1px rgba(255,255,255,0.22), inset 0 -12px 20px -14px rgba(255,255,255,0.35)`,
}}
>
{/* top gloss */}
<span
aria-hidden
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(180deg, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.08) 34%, rgba(255,255,255,0) 58%)",
opacity: 0.6,
pointerEvents: "none",
}}
/>
{/* pointer specular */}
<motion.span
aria-hidden
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(${G.sheen.size}px circle at var(--mx, 50%) var(--my, 0%), ${G.sheen.color}, transparent 62%)`,
mixBlendMode: "screen",
opacity: sheenOpacity,
pointerEvents: "none",
}}
/>
<span
className="relative z-10 flex w-full items-center"
style={{ gap: 11 }}
>
<span
className="relative grid shrink-0 place-items-center"
style={{
width: G.icon.size,
height: G.icon.size,
borderRadius: G.icon.radius,
border: "1px solid rgba(255, 255, 255, 0.35)",
background:
"linear-gradient(160deg, rgba(255,255,255,0.36) 0%, rgba(255,255,255,0.10) 100%)",
boxShadow:
"inset 0 1px 0 rgba(255,255,255,0.5), 0 2px 6px rgba(0,0,0,0.15)",
}}
>
{icon ?? <MessageGlyph size={G.icon.glyph} />}
<span
aria-hidden
style={{
position: "absolute",
top: -2,
right: -2,
width: G.icon.dot,
height: G.icon.dot,
borderRadius: 999,
background: "#3DDC84",
border: "2px solid rgba(255,255,255,0.3)",
}}
/>
</span>
<span className="min-w-0 flex-1">
<span
className="block truncate"
style={{
fontSize: G.label.size,
fontWeight: G.label.weight,
lineHeight: G.label.lineHeight,
letterSpacing: G.label.tracking,
color: "#FFFFFF",
textShadow: "0 1px 3px rgba(8, 10, 18, 0.45)",
}}
>
{title}
</span>
<span
className="mt-0.5 block truncate"
style={{
fontSize: G.label.subSize,
color: G.label.subColor,
textShadow: "0 1px 3px rgba(8, 10, 18, 0.45)",
}}
>
{subtitle}
</span>
</span>
<svg
aria-hidden
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
style={{ color: "rgba(255,255,255,0.7)" }}
>
<path
d="m6 3.5 4.5 4.5L6 12.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</motion.span>
</motion.button>
);
}
export function MessageGlyph({ size = 19 }: { size?: number }) {
return (
<svg
aria-hidden
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
>
<path
d="M5.1 5.4h13.8c1 0 1.8.8 1.8 1.8v7.6c0 1-.8 1.8-1.8 1.8h-8.4l-4.1 3.2v-3.2H5.1c-1 0-1.8-.8-1.8-1.8V7.2c0-1 .8-1.8 1.8-1.8Z"
fill="rgba(255,255,255,0.94)"
/>
<circle cx="8.3" cy="11" r="1.05" fill="#4A5362" />
<circle cx="12" cy="11" r="1.05" fill="#4A5362" />
<circle cx="15.7" cy="11" r="1.05" fill="#4A5362" />
</svg>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
复刻 iOS 消息通知上的液态玻璃:一层毛玻璃让底下的团花纹仍然透出来,1px 高光边圈住按钮,下方 9px 的玻璃厚边把它托起来;按下时被压扁,松手后过冲回弹,像一块果冻。演示舞台提供四款底纹,点击即可更换按钮下面的背景。
- 背板模糊
- 10 px
- 玻璃着色
- 0.13
- 玻璃厚度
- 9 px
- 果冻回弹
- 0.6
- 指针高光
- 开启
- 按钮
- 292 × 64 px · 22px
- 背板滤镜
- blur(10px) · saturate(1.7)
- 玻璃厚边
- 9 px
- 按下形变
- 1.05 × 0.86 · y +55%
- 回落弹簧
- 460 / 22.8
- 高光半径
- 150px · 0.9
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 透过玻璃能清楚看到底纹:切换四款底纹时,按钮内部的颜色与纹理随之改变,没有不透明填充。
- 圆角外没有模糊溢出或光晕;1px 高光边完整包住玻璃面。
- 按钮下方有一条可见的玻璃厚边;按下时它被压薄,松手后恢复原厚度。
- 按下是快速的压扁,松手只过冲一次并在约 0.6s 内稳定,没有持续抖动。
- 文字的对比度在四款底纹上都足够;按钮在触屏、鼠标和键盘下都可点击。
- prefers-reduced-motion 下不播放形变与高光,按钮仍保留焦点环并可触发 click。
- 零控制台报错;卸载后监听与动画都被清理;只改变 transform / opacity / 背板滤镜。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。