前言
在前端视觉效果不断进化的今天,一个简单的天气卡片,早已不只是展示温度这么简单。本项目通过 Canvas 粒子系统 + CSS 毛玻璃 + 物理交互,实现了一个"有生命"的天气 UI------它会响应你的鼠标、会下雨下雪、甚至会结霜。
这篇文章带你完整拆解这个效果的实现思路。
一、整体设计思路
这个天气卡片的核心目标不是"功能",而是沉浸感,主要通过 4 个维度实现:
- 视觉层次
- 背景渐变(晴 / 雨 / 雪)
- 毛玻璃卡片(Glassmorphism)
- 噪点纹理(提升真实感)
- 动态效果
- 粒子系统(雨滴 / 雪花)
- 雷电闪烁
- 水波纹(雨滴打在卡片上)
- 交互反馈
- 鼠标驱动 3D 旋转
- 粒子"躲避鼠标"
- 视差(parallax)
- 环境模拟
- 雪天结霜
- 雨天模糊增强(湿度感)
- 阳光光斑
二、核心模块拆解
1. 毛玻璃卡片(Glassmorphism)
.glass-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.4);
}
📌 关键点:
backdrop-filter是核心(注意 Safari 需要-webkit-前缀)- 半透明 + 边框 + 阴影 = 真实玻璃感
👉 坑点提醒:
- 在部分 Windows 浏览器上性能较差
- 不支持时会"失效"
2. Canvas 粒子系统(雨 / 雪)
核心类:
class Particle {
constructor(type) {
this.type = type;
this.reset();
}
reset() {
this.x = Math.random() * w;
this.y = Math.random() * -h;
}
update() {
this.x += this.vx;
this.y += this.vy;
}
draw() {
// 根据类型绘制
}
}
雨 vs 雪的区别
| 属性 | 雨 | 雪 |
|---|---|---|
| 速度 | 快 | 慢 |
| 形状 | 线条 | 圆点 |
| 风影响 | 明显 | 柔和 |
3. 鼠标交互(粒子躲避)
这是本项目最"高级"的点之一👇
const dx = this.x - mouseX;
const dy = this.y - mouseY;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < radius) {
const force = (radius - dist) / radius;
this.x += Math.cos(angle) * force * 15;
this.y += Math.sin(angle) * force * 15;
}
💡 本质:简单的力场模拟
👉 可以优化:
- 用平方距离代替
sqrt(性能更好) - 引入 easing,让运动更丝滑
4. 3D 视差 + 卡片倾斜
const rotateX = -((y - centerY) / centerY) * 10;
const rotateY = ((x - centerX) / centerX) * 10;
weatherCard.style.transform = `
perspective(1000px)
rotateX(${rotateX}deg)
rotateY(${rotateY}deg)
`;
📌 核心点:
- 鼠标位置 → 映射为旋转角度
- 加
perspective才有立体感
5. 结霜效果(雪天专属)
.frost-overlay {
background: radial-gradient(
circle at center,
transparent 60%,
rgba(255,255,255,0.4) 100%
);
mix-blend-mode: overlay;
}
💡 思路:
- 用渐变模拟边缘霜
mix-blend-mode融合背景
👉 更高级做法:
- 使用噪声贴图(noise texture)
- 或 WebGL shader(更真实)
6. 雷电闪烁
function triggerLightning() {
lightningLayer.style.background = 'rgba(255,255,255,0.8)';
setTimeout(() => {
lightningLayer.style.background = 'transparent';
}, 80);
}
📌 技巧:
- 快速闪 + 随机二次闪
- 不用 Canvas,直接 DOM 更轻量
7. 水波纹(雨滴命中卡片)
class Ripple {
update() {
this.radius += 1;
this.opacity -= 0.03;
}
}
💡 本质:
- 半透明椭圆不断扩大 + 消失
- 模拟水滴扩散
三、状态切换设计
function setWeather(type) {
if (type === 'rain') {
initParticles('rain', 400);
} else if (type === 'snow') {
initParticles('snow', 200);
}
}
📌 思路:
- UI + 动效 + 数据统一切换
- 每种天气都是一个"状态机"
完整代码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>天气卡片</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap');
body {
font-family: 'Inter', sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
transition: background 1s ease;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #1a1a1a;
}
/* 背景容器 */
.bg-container {
position: absolute;
top: -10%;
left: -10%;
width: 120%;
height: 120%;
z-index: -2;
transition: background 1s ease, transform 0.1s ease-out;
}
.bg-sunny { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
.bg-rain { background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%); }
.bg-snow { background: linear-gradient(135deg, #83a4d4 0%, #b6fbff 100%); }
/* 闪电层 */
#lightningLayer {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(255, 255, 255, 0);
z-index: -1; pointer-events: none;
transition: background 0.1s;
}
/* 毛玻璃卡片 */
.glass-card {
position: relative;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
transform-style: preserve-3d;
will-change: transform;
transition: backdrop-filter 1s ease; /* 模糊度过渡 */
}
/* 结霜特效层 (仅在下雪时显示) */
.frost-overlay {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 2s ease;
/* 使用径向渐变模拟边缘结霜 */
background: radial-gradient(circle at center, transparent 60%, rgba(255, 255, 255, 0.4) 100%);
mix-blend-mode: overlay;
border-radius: 1.5rem; /* 匹配卡片圆角 */
}
.frost-overlay.active { opacity: 1; }
/* 噪点纹理 */
.glass-noise {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
opacity: 0.06; z-index: 0; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
/* 阳光光斑 */
.sun-ray-effect {
position: absolute;
top: 10%; left: 10%; width: 80%; height: 80%;
background: radial-gradient(circle at 70% 30%, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0) 60%);
border-radius: 50%;
opacity: 0; pointer-events: none;
transition: opacity 1s ease;
mix-blend-mode: overlay; filter: blur(20px);
}
.sun-ray-effect.active { opacity: 1; }
/* 视差元素 */
.sun {
position: absolute; top: -60px; right: -60px; width: 150px; height: 150px;
background: linear-gradient(to bottom right, #ffeb3b, #fbc02d);
border-radius: 50%;
box-shadow: 0 0 40px rgba(255, 235, 59, 0.6), 0 0 80px rgba(251, 192, 45, 0.4);
z-index: 1; opacity: 0; transition: opacity 1s ease;
}
.sun.active { opacity: 1; }
.cloud {
position: absolute;
background: linear-gradient(to bottom, rgba(255,255,255,0.9), rgba(255,255,255,0.7));
border-radius: 50px; z-index: 2; opacity: 0;
transition: opacity 1s ease;
filter: drop-shadow(0 5px 15px rgba(0,0,0,0.1));
}
.cloud.active { opacity: 0.9; }
.cloud::after, .cloud::before { content: ''; position: absolute; background: inherit; border-radius: 50%; }
.cloud-1 { width: 120px; height: 45px; top: 30px; left: -20px; }
.cloud-1::after { width: 50px; height: 50px; top: -25px; left: 20px; }
.cloud-1::before { width: 40px; height: 40px; top: -15px; left: 60px; }
.cloud-2 { width: 90px; height: 35px; top: 120px; left: 280px; }
.cloud-2::after { width: 35px; height: 35px; top: -18px; left: 15px; }
/* Canvas */
#weatherCanvas, #cardEffectCanvas { position: absolute; top: 0; left: 0; pointer-events: none; }
#weatherCanvas { width: 100%; height: 100%; z-index: 0; }
#cardEffectCanvas { z-index: 4; }
/* UI */
.toggle-btn { transition: all 0.3s; position: relative; overflow: hidden; }
.toggle-btn::after {
content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(255,255,255,0.2); transform: translateX(-100%); transition: transform 0.3s;
}
.toggle-btn:hover::after { transform: translateX(0); }
.toggle-btn.active { background: rgba(255,255,255,0.4); color: #000; font-weight: 700; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.text-shadow { text-shadow: 0 2px 8px rgba(0,0,0,0.15); }
.card-content { transform: translateZ(20px); }
</style>
</head>
<body class="relative">
<div id="bgContainer" class="bg-container bg-sunny"></div>
<div id="lightningLayer"></div>
<canvas id="weatherCanvas"></canvas>
<div id="cardWrapper" class="perspective-container">
<div id="weatherCard" class="glass-card w-80 sm:w-96 rounded-3xl p-8 text-white overflow-hidden transform transition-transform duration-100 ease-out">
<div class="glass-noise"></div>
<div id="frostOverlay" class="frost-overlay"></div> <!-- 结霜层 -->
<canvas id="cardEffectCanvas"></canvas>
<div id="sun" class="sun active" data-parallax="0.05"></div>
<div id="sunRayEffect" class="sun-ray-effect active"></div>
<div id="cloud1" class="cloud cloud-1 active" data-parallax="0.08"></div>
<div id="cloud2" class="cloud cloud-2 active" data-parallax="0.04"></div>
<div class="card-content relative z-10">
<div class="flex justify-between items-start mb-8">
<div>
<h2 class="text-lg font-bold tracking-wide flex items-center gap-2">
<i class="fas fa-location-arrow text-sm"></i> 上海
</h2>
<p id="dateText" class="text-xs text-white/80 mt-1 font-medium opacity-80">周二, 11月 19日</p>
</div>
<div class="text-right">
<i id="weatherIcon" class="fas fa-sun text-4xl text-yellow-300 drop-shadow-lg filter"></i>
</div>
</div>
<div class="flex flex-col items-center justify-center mb-10">
<div class="flex items-start relative">
<span id="tempValue" class="text-8xl font-thin tracking-tighter text-shadow">26</span>
<span class="text-4xl mt-4 font-light opacity-80">°</span>
</div>
<p id="weatherDesc" class="text-xl font-medium tracking-wider mt-2 opacity-90">晴朗舒适</p>
</div>
<div class="grid grid-cols-3 gap-4 border-t border-white/20 pt-6 mb-6">
<div class="flex flex-col items-center group cursor-default">
<i class="fas fa-wind mb-2 opacity-60 group-hover:scale-110 transition-transform"></i>
<span id="windValue" class="font-semibold text-sm">3 km/h</span>
</div>
<div class="flex flex-col items-center border-l border-r border-white/10 group cursor-default">
<i class="fas fa-droplet mb-2 opacity-60 group-hover:scale-110 transition-transform"></i>
<span id="humidValue" class="font-semibold text-sm">45%</span>
</div>
<div class="flex flex-col items-center group cursor-default">
<i class="fas fa-temperature-high mb-2 opacity-60 group-hover:scale-110 transition-transform"></i>
<span id="feelsLikeValue" class="font-semibold text-sm">28°</span>
</div>
</div>
<div class="flex justify-between bg-black/20 rounded-2xl p-1.5 backdrop-blur-md shadow-inner">
<button data-weather="sunny" class="toggle-btn active flex-1 py-2.5 rounded-xl text-xs text-white">
<i class="fas fa-sun mb-1 block text-sm"></i> 晴天
</button>
<button data-weather="rain" class="toggle-btn flex-1 py-2.5 rounded-xl text-xs text-white">
<i class="fas fa-bolt mb-1 block text-sm"></i> 雷雨
</button>
<button data-weather="snow" class="toggle-btn flex-1 py-2.5 rounded-xl text-xs text-white">
<i class="fas fa-snowflake mb-1 block text-sm"></i> 下雪
</button>
</div>
</div>
</div>
</div>
<script>
(function() {
const dateElement = document.getElementById('dateText');
const bgContainer = document.getElementById('bgContainer');
const lightningLayer = document.getElementById('lightningLayer');
const weatherCanvas = document.getElementById('weatherCanvas');
const weatherCtx = weatherCanvas.getContext('2d');
const cardEffectCanvas = document.getElementById('cardEffectCanvas');
const cardEffectCtx = cardEffectCanvas.getContext('2d');
const weatherCard = document.getElementById('weatherCard');
const parallaxElements = document.querySelectorAll('[data-parallax]');
const frostOverlay = document.getElementById('frostOverlay');
const sun = document.getElementById('sun');
const sunRayEffect = document.getElementById('sunRayEffect');
const cloud1 = document.getElementById('cloud1');
const cloud2 = document.getElementById('cloud2');
const tempValue = document.getElementById('tempValue');
const weatherDesc = document.getElementById('weatherDesc');
const weatherIcon = document.getElementById('weatherIcon');
const windValue = document.getElementById('windValue');
const humidValue = document.getElementById('humidValue');
const feelsLikeValue = document.getElementById('feelsLikeValue');
const btns = document.querySelectorAll('.toggle-btn');
const today = new Date();
dateElement.innerText = today.toLocaleDateString('zh-CN', { weekday: 'long', month: 'short', day: 'numeric' });
let w, h;
let cardRect;
let particles = [];
let weatherType = 'sunny';
let windSpeed = 0;
let time = 0;
// 鼠标位置跟踪 (用于交互)
let mouseX = -1000;
let mouseY = -1000;
function resize() {
w = weatherCanvas.width = window.innerWidth;
h = weatherCanvas.height = window.innerHeight;
cardRect = weatherCard.getBoundingClientRect();
cardEffectCanvas.width = cardRect.width;
cardEffectCanvas.height = cardRect.height;
}
window.addEventListener('resize', resize);
resize();
// --- 视差与交互核心 ---
function handleMove(x, y) {
mouseX = x;
mouseY = y;
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const rotateX = -((y - centerY) / centerY) * 10;
const rotateY = ((x - centerX) / centerX) * 10;
weatherCard.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
parallaxElements.forEach(el => {
const speed = parseFloat(el.getAttribute('data-parallax'));
const offX = (x - centerX) * speed;
const offY = (y - centerY) * speed;
el.style.transform = `translate(${offX}px, ${offY}px)`;
});
bgContainer.style.transform = `translate(${-rotateY * 0.5}px, ${-rotateX * 0.5}px) scale(1.1)`;
}
// 鼠标移动
document.addEventListener('mousemove', (e) => handleMove(e.clientX, e.clientY));
// 触摸移动 (支持手机)
document.addEventListener('touchmove', (e) => {
handleMove(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
// 陀螺仪支持 (可选,部分设备需要权限)
if (window.DeviceOrientationEvent) {
window.addEventListener('deviceorientation', (e) => {
if (!e.beta || !e.gamma) return; // 某些桌面环境可能没有数据
// 简单的映射
const x = (e.gamma + 90) / 180 * window.innerWidth;
const y = (e.beta + 90) / 180 * window.innerHeight;
// 这里不直接覆盖 handleMove,因为可能会冲突,实际项目通常会做一个输入源判断
// 仅做演示:如果鼠标不在屏幕内,使用陀螺仪
if (mouseX === -1000) {
handleMove(x, y);
}
});
}
// --- 粒子系统 ---
class Particle {
constructor(type) {
this.type = type;
this.reset();
}
reset() {
this.x = Math.random() * w;
this.y = Math.random() * -h;
if (this.type === 'rain') {
this.vy = Math.random() * 15 + 15;
this.vxBase = Math.random() * 1 - 0.5;
this.len = Math.random() * 20 + 10;
this.opacity = Math.random() * 0.4 + 0.2;
} else if (this.type === 'snow') {
this.vy = Math.random() * 2 + 1;
this.vxBase = Math.random() * 2 - 1;
this.size = Math.random() * 3 + 2;
this.opacity = Math.random() * 0.6 + 0.4;
}
}
update() {
// 风力
let currentVx = this.vxBase + windSpeed;
// --- 鼠标交互场 (Repel Effect) ---
const dx = this.x - mouseX;
const dy = this.y - mouseY;
const dist = Math.sqrt(dx*dx + dy*dy);
const interactRadius = 150; // 交互半径
if (dist < interactRadius) {
const force = (interactRadius - dist) / interactRadius; // 距离越近力度越大
const angle = Math.atan2(dy, dx);
const pushX = Math.cos(angle) * force * 15; // 推力
const pushY = Math.sin(angle) * force * 15;
this.x += pushX;
this.y += pushY;
}
// ----------------------------------
this.x += currentVx;
this.y += this.vy;
if (this.type === 'rain') {
const relX = this.x - cardRect.left;
const relY = this.y - cardRect.top;
if (relX > 0 && relX < cardRect.width && relY > 0 && relY < cardRect.height) {
if (Math.random() < 0.05) createRipple(relX, relY);
}
}
if (this.y > h || this.x > w + 50 || this.x < -50) this.reset();
}
draw() {
weatherCtx.beginPath();
if (this.type === 'rain') {
weatherCtx.strokeStyle = `rgba(255, 255, 255, ${this.opacity})`;
weatherCtx.lineWidth = 1.5;
weatherCtx.moveTo(this.x, this.y);
weatherCtx.lineTo(this.x + (windSpeed * 2), this.y + this.len);
weatherCtx.stroke();
} else if (this.type === 'snow') {
weatherCtx.fillStyle = `rgba(255, 255, 255, ${this.opacity})`;
weatherCtx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
weatherCtx.fill();
}
}
}
let ripples = [];
class Ripple {
constructor(x, y) {
this.x = x; this.y = y; this.radius = 0;
this.maxRadius = Math.random() * 15 + 5;
this.opacity = 0.8; this.speed = 1;
}
update() { this.radius += this.speed; this.opacity -= 0.03; }
draw() {
cardEffectCtx.beginPath();
cardEffectCtx.strokeStyle = `rgba(255, 255, 255, ${this.opacity})`;
cardEffectCtx.lineWidth = 1.5;
cardEffectCtx.ellipse(this.x, this.y, this.radius, this.radius * 0.6, 0, 0, Math.PI * 2);
cardEffectCtx.stroke();
}
}
function createRipple(x, y) { ripples.push(new Ripple(x, y)); }
function initParticles(type, count) {
particles = [];
for(let i=0; i<count; i++) particles.push(new Particle(type));
}
function animate() {
weatherCtx.clearRect(0, 0, w, h);
cardEffectCtx.clearRect(0, 0, cardEffectCanvas.width, cardEffectCanvas.height);
time += 0.01;
windSpeed = Math.sin(time) * 2;
if (weatherType !== 'sunny') {
particles.forEach(p => { p.update(); p.draw(); });
}
for (let i = ripples.length - 1; i >= 0; i--) {
let r = ripples[i];
r.update(); r.draw();
if (r.opacity <= 0) ripples.splice(i, 1);
}
if (weatherType === 'rain' && Math.random() < 0.005) triggerLightning();
requestAnimationFrame(animate);
}
function triggerLightning() {
lightningLayer.style.background = 'rgba(255, 255, 255, 0.8)';
setTimeout(() => {
lightningLayer.style.background = 'rgba(255, 255, 255, 0)';
setTimeout(() => {
if(Math.random() > 0.5) {
lightningLayer.style.background = 'rgba(255, 255, 255, 0.4)';
setTimeout(() => lightningLayer.style.background = 'rgba(255, 255, 255, 0)', 50);
}
}, 100);
}, 80);
}
function setWeather(type, btn) {
weatherType = type;
btns.forEach(b => b.classList.remove('active'));
if(btn) btn.classList.add('active');
bgContainer.className = 'bg-container';
ripples = [];
if (type === 'sunny') {
bgContainer.classList.add('bg-sunny');
sun.classList.add('active');
sunRayEffect.classList.add('active');
cloud1.classList.add('active');
cloud2.classList.add('active');
frostOverlay.classList.remove('active'); // 移除霜
weatherCard.style.backdropFilter = 'blur(16px)'; // 恢复默认模糊
tempValue.innerText = "26";
weatherDesc.innerText = "晴朗舒适";
weatherIcon.className = "fas fa-sun text-4xl text-yellow-300 drop-shadow-lg filter";
windValue.innerText = "3 km/h";
humidValue.innerText = "45%";
feelsLikeValue.innerText = "28°";
particles = [];
} else if (type === 'rain') {
bgContainer.classList.add('bg-rain');
sun.classList.remove('active');
sunRayEffect.classList.remove('active');
cloud1.classList.add('active');
cloud2.classList.add('active');
frostOverlay.classList.remove('active');
weatherCard.style.backdropFilter = 'blur(24px)'; // 雨天玻璃更模糊(水汽)
tempValue.innerText = "19";
weatherDesc.innerText = "雷阵雨";
weatherIcon.className = "fas fa-bolt text-4xl text-yellow-200 drop-shadow-lg filter animate-pulse";
windValue.innerText = "18 km/h";
humidValue.innerText = "92%";
feelsLikeValue.innerText = "17°";
initParticles('rain', 400);
} else if (type === 'snow') {
bgContainer.classList.add('bg-snow');
sun.classList.remove('active');
sunRayEffect.classList.remove('active');
cloud1.classList.remove('active');
cloud2.classList.remove('active');
frostOverlay.classList.add('active'); // 添加霜
weatherCard.style.backdropFilter = 'blur(20px)'; // 雪天稍微模糊
tempValue.innerText = "-4";
weatherDesc.innerText = "大雪纷飞";
weatherIcon.className = "fas fa-snowflake text-4xl text-white drop-shadow-lg filter";
windValue.innerText = "8 km/h";
humidValue.innerText = "88%";
feelsLikeValue.innerText = "-9°";
initParticles('snow', 200);
}
}
btns.forEach(btn => {
btn.addEventListener('click', function() {
setWeather(this.getAttribute('data-weather'), this);
});
});
animate();
setWeather('sunny', btns[0]);
})();
</script>
</body>
</html>