一直觉得博客主页的卡片不够好看了,没有任何交互体验。于是决定把液态玻璃效果移植到个人资料卡片和文章列表上,再加点 3D 倾斜交互,让整页"活"起来。
效果概述
-
液态玻璃质感:毛玻璃模糊 + WebGL 渲染纹理 + SVG 三层边框
-
3D 倾斜交互:鼠标在卡片内移动,卡片跟随旋转,光照追踪鼠标位置
-
内容悬浮:文字与卡片表面产生视差,动态投影强化深度感
-
模块化封装:JS 自动扫描
data-glass-card属性初始化 -
独立参数配置:
glass-card-config.js按选择器分别设置参数
文件结构
themes/particlex/
├── source/
│ ├── js/
│ │ ├── glass-card.js # 核心逻辑(WebGL / SVG 边框 / 动画循环)
│ │ └── glass-card-config.js # 参数配置(按选择器独立调参)
│ └── css/
│ └── main.css # 玻璃层样式 + 卡片 3D 变换
└── layout/
├── layout.ejs # 引入脚本
├── card.ejs # 个人资料卡片 HTML 结构
└── posts.ejs # 文章卡片 HTML 结构
核心实现
1. HTML 四层结构
每个卡片由四个层叠组成,从下到上依次是:
第一层 — WebGL 玻璃纹理:<canvas> 通过 WebGL 片段着色器渲染噪声纹理,使用 mix-blend-mode: screen 叠加到卡片背景上,产生类似液态玻璃的折射效果。Canvas 尺寸比卡片大 40px,避免旋转时边缘露白。
第二层 — SVG 三层边框:用三次贝塞尔曲线(K = 0.5522847498)绘制完美圆角矩形路径,三层分别是:基底描边(rgba(255,255,255,.12))、高光描边(rgba(255,255,255,.35) 带模糊)、发光描边(rgba(255,255,255,.18) 带更大模糊)。SVG 使用 preserveAspectRatio="none" 自适应卡片尺寸。
第三层 — 表面光照:glass-card-surface 使用径向渐变模拟光源追踪,中心点通过 CSS 变量 --light-x 和 --light-y 动态移动,配合线性渐变模拟玻璃的斜向光泽。
第四层 — 内容:glass-content 包裹原有内容,设置 z-index: 30 确保在最上层。
<div id="card-style" class="glass-card" data-glass-card>
<!-- 第一层:WebGL Canvas + 表面光 -->
<div class="glass-card-inner">
<canvas class="glass-card-canvas"></canvas>
<div class="glass-card-surface"></div>
</div>
<!-- 第二层:SVG 三层边框 -->
<svg class="glass-card-border" viewBox="0 0 300 500" preserveAspectRatio="none">
<path class="glass-card-border-base"></path>
<path class="glass-card-border-highlight"></path>
<path class="glass-card-border-glow"></path>
</svg>
<!-- 第四层:内容 -->
<div id="card-div" class="glass-content">
<div class="avatar">...</div>
<div class="name">...</div>
...
</div>
</div>
所有玻璃层(第一、二、三层)都使用 position: absolute; pointer-events: none,不占用文档流也不拦截鼠标事件。只需给任意元素加 data-glass-card 属性,JS 自动扫描初始化。
2. 初始化流程
页面加载时,glass-card.js 自动扫描所有 [data-glass-card] 元素,对每个执行 initCard:
-
读取
glass-card-config.js中的配置,按 CSS 选择器匹配当前卡片,获取对应的tilt(倾斜幅度)和lerp(平滑速度)。如果 HTML 元素上有data-tilt或data-lerp属性,优先使用 HTML 属性值作为覆盖。 -
初始化 CSS 变量
--rx: 0deg、--ry: 0deg、--light-x: 50%、--light-y: 50%,确保卡片初始状态水平。 -
绑定三个事件:
mouseenter激活倾斜、mousemove更新鼠标坐标、mouseleave关闭倾斜并回弹。进入时清除 CSS transition 让 JS 实时控制,离开时恢复transition: transform 0.5s ease让卡片平滑回到原位。 -
调用
updateBorder根据卡片实际尺寸计算 SVG 边框路径,调用initWebGL初始化 WebGL 着色器。
3. 倾斜计算
全局鼠标位置经过平滑处理(mouseX += (targetMouseX - mouseX) * 0.12),避免抖动。每帧动画中,计算鼠标在卡片内的相对坐标(-1 到 1),映射为旋转角度:
var centerX = rect.left + rect.width / 2;
var centerY = rect.top + rect.height / 2;
var nx = (mouseX - centerX) / (rect.width / 2);
var ny = (mouseY - centerY) / (rect.height / 2);
nx = Math.max(-1, Math.min(1, nx));
ny = Math.max(-1, Math.min(1, ny));
d.targetRx = -ny * d.tilt; // 鼠标在上方,卡片向后仰
d.targetRy = nx * d.tilt; // 鼠标在右侧,卡片向右转
targetRx 和 targetRy 是目标角度,通过平滑插值逐步逼近:
d.rx += (d.targetRx - d.rx) * d.lerp;
d.ry += (d.targetRy - d.ry) * d.lerp;
lerp 越大响应越快,越小越迟缓。计算出的角度写入 CSS 变量:
d.el.style.setProperty("--rx", d.rx + "deg");
d.el.style.setProperty("--ry", d.ry + "deg");
CSS 消费这些变量驱动 3D 旋转变换:
.card {
--rx: 0deg;
--ry: 0deg;
transform: rotateX(var(--rx)) rotateY(var(--ry));
transform-style: preserve-3d;
will-change: transform;
}
4. 光照追踪
光照坐标同样映射到 0~100% 范围,写入 --light-x 和 --light-y。glass-card-surface 的径向渐变中心点由这两个变量控制:
d.targetLx = ((mouseX - rect.left) / rect.width) * 100;
d.targetLy = ((mouseY - rect.top) / rect.height) * 100;
d.lx += (d.targetLx - d.lx) * d.lerp;
d.ly += (d.targetLy - d.ly) * d.lerp;
d.el.style.setProperty("--light-x", d.lx + "%");
d.el.style.setProperty("--light-y", d.ly + "%");
CSS 中径向渐变实时跟随:
.glass-card-surface {
background: radial-gradient(
140px circle at var(--light-x) var(--light-y),
rgba(255,255,255,.14),
rgba(255,255,255,.025) 30%,
transparent 65%
);
}
5. 内容悬浮
这是调试最久的部分。最初尝试用 translateZ 把文字层推离卡片表面,利用 preserve-3d 实现真正的 3D 深度。但 backdrop-filter 会强制扁平化 3D 子元素,translateZ 完全不生效。filter: drop-shadow() 也试过,同样会破坏 3D 渲染上下文。
最终方案:放弃 Z 轴,改用 X/Y 平面偏移 + 动态 textShadow 来模拟悬浮。文字随卡片倾斜反向滑动(卡片向右倾,文字向左滑),阴影也跟随偏移方向:
// 按卡片宽度等比缩放,sqrt 避免宽卡片偏移过大
var scale = Math.sqrt(rect.width / 300);
// 水平偏移:卡片绕 Y 轴旋转,文字反向移动
var px = -d.ry * 1.0 * scale;
// 垂直偏移:卡片绕 X 轴旋转,文字反向移动
var py = d.rx * 0.9 * scale;
// 阴影偏移:跟随文字位移方向
var sx = d.ry * 0.6 * scale;
var sy = -d.rx * 0.6 * scale;
d.content.style.transform =
"translate(" + px + "px, " + py + "px)";
d.content.style.textShadow =
sx + "px " + sy + "px 4px rgba(0,0,0,0.22)";
300px 宽的侧边栏 scale = 1.0,800px 宽的卡片 scale ≈ 1.63,视觉效果统一。
6. 配置文件
window.GlassCardConfig = {
tilt: 18, // 默认倾斜幅度(度)
lerp: 0.18, // 默认平滑插值速度(0~1)
mouseSmooth: 0.12, // 全局鼠标平滑
cards: {
"#card-style": { tilt: 18, lerp: 0.18 }, // 个人资料
".post": { tilt: 25, lerp: 0.15 } // 文章卡片
}
};
优先级:HTML data-tilt / data-lerp 属性 > 配置文件 cards 匹配 > 配置文件全局默认值 > 硬编码兜底值。新增卡片只需在 cards 里加一条选择器 + HTML 加 data-glass-card 即可。
踩坑记录
| 问题 | 原因 | 解决 |
|---|---|---|
| 卡片内容消失 | 玻璃层没有绝对定位,在正常文档流中把内容挤出去了 | 所有玻璃层设 position: absolute; pointer-events: none |
| 倾斜效果不明显 | 初始角度只有 4°,CSS transition: transform 0.3s 拖慢 JS 动画 |
增大到 18°~25°,去掉 transform 的 transition |
| 文章卡片倾斜无效 | .post 缺少 --rx --ry 变量和 transform 声明 |
补全 CSS 变量和 rotateX/rotateY 变换 |
| 文字悬浮完全无效 | filter: drop-shadow() 和 translateZ 都被 backdrop-filter 扁平化 |
改用 X/Y 偏移 + JS 动态 textShadow |
| 文章卡片悬浮弱 | 宽卡片上像素偏移占比太小 | 引入 sqrt(宽度/300) 按宽度等比放大 |
| 四角文字被裁切 | overflow: hidden + 位移过大 |
平方根缩放替代线性缩放,控制位移范围 |
总结
从一张 2500 行的参考 HTML 出发,提取核心逻辑封装为可复用模块。遇到的主要问题都是 CSS 属性互相打架:backdrop-filter 扁平化 3D、transition 和 JS 动画冲突、filter 破坏渲染上下文。最终效果:鼠标划过卡片时有液态玻璃的折射光泽 + 3D 倾斜 + 文字悬浮,交互感拉满。
PS: 懒得写文章,就用 AI 写了一篇记录一下