不同尺寸设备上看网页动画,经常出现位置跑偏、缩放失常的糟心事。这里分享从实际项目里摸出来的处理经验,包括流体动画用视口单位、SVG的viewBox妙用、容器查询新特性,还有FLIP技术搞定复杂布局切换。针对手机和电脑的不同交互方式,也能用媒体查询做精准控制。GSAP的matchMedia和ScrollTrigger插件能省不少力气。这些方法能解决大部分适配难题。
流体动画
动画在不同屏幕宽度下自动伸缩,靠的就是流体思路。核心原则:别跟浏览器较劲,给它定好规则就行。比如移动一个方块,如果写死left: 200px,换到手机屏幕上直接就飞出边界了。换成视口单位或百分比,浏览器自己会算。
实操一个卡片飞入效果:
.box {
transform: translateX(100%);
animation: slideIn 0.5s forwards;
}
@keyframes slideIn {
to { transform: translateX(0); }
}这里用百分比位移,不管父容器多宽,最终位置都在原位。要是改成translateX(300px),小屏设备上就露馅了。
另外注意,改left/top这类布局属性会触发重排,动画一卡一卡的像幻灯片。必须用transform和opacity,浏览器走合成器线程,丝滑很多。
流体动画里另一个神器是视口单位。假设做一个跟着屏幕高度走的入场效果:
.hero {
transform: translateY(100vh);
transition: transform 0.8s;
}
.hero.active {
transform: translateY(0);
}100vh代表整个视口高度,无论手机还是带鱼屏,元素总能从屏幕外滑进来。但要留意移动端地址栏收起时vh会变,动画终点可能抖一下。折中办法用min-height: -webkit-fill-available做垫底。
SVG单位
SVG天生就是响应式选手,名字里“可缩放”不是白叫的。关键在viewBox属性——它定义了画布上的坐标网格。比如viewBox="0 0 100 50",宽100个单位、高50个单位。这时候把圆点从x=0移到x=100,它永远横跨整个SVG宽度,不关心实际像素。
实际操作一个加载动画:
<svg viewBox="0 0 200 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="0" cy="50" r="10" fill="#f90">
<animate attributeName="cx" values="0;200;0" dur="1s" repeatCount="indefinite" />
</circle>
</svg>圆点从左边滚到右边,始终占满容器宽度。但有个坑:如果父级SVG被CSS限制了宽高比,圆点可能被压扁。解决方案是加preserveAspectRatio,例如preserveAspectRatio="MidYMid meet"让内容居中且完整显示,或者用slice模式裁剪填充。
手机上看小动画时,常常希望它不超出某块区域。套一个固定高度的父容器,SVG设overflow: visible,再配合preserveAspectRatio="MinYMin meet",宽屏下两侧多出的舞台内容会自然露出来,窄屏下自动裁掉两边。
容器查询
以前做响应式只能拿浏览器宽度当裁判,现在容器查询来了——直接问父元素“你多宽”。Chrome和Safari已经支持,Firefox也快了。定义容器:
.card-container {
container-type: inline-size;
}
.card {
animation: slide 0.3s;
}
@container (min-width: 400px) {
.card {
animation-duration: 0.6s;
}
}父容器宽超过400px时,动画时长翻倍。这招特别适合组件库里的卡片、轮播图,不用再监听全局resize。
写代码时记得把container-type加给包裹层,不是动画元素本身。如果动画依赖父容器宽度的具体数值(比如移动距离等于父宽的一半),可以用容器单位cqw:transform: translateX(50cqw),1cqw等于父容器宽度的1%。再也不用JS算偏移量了。
FLIP技巧
有时候布局变化太大,比如弹窗从按钮位置飞到屏幕中央,或者列表项换了个父容器。直接改位置根本没法加过渡动画。FLIP技术就是专治这种“不可能动画”的。
名字拆开看:First(记录起点),Last(记录终点),Invert(算出偏移量并反向施加transform),Play(移除反向transform)。手动实现挺麻烦,但GSAP的FLIP插件一键搞定。
操作流程(拿一个卡片移动到另一个列表举例):
let state = Flip.getState('.card');
document.querySelector('.new-list').appendChild(card);
Flip.from(state, {
duration: 0.5,
ease: 'power1.inOut',
scale: true
});插件会自动抓取卡片移动前后的所有变化(位置、大小、旋转),然后补间回去。注意一点:移动后必须等DOM更新完再调用Flip.from,不然拿到的“Last”状态不对。如果移动过程中还有其他样式冲突,可以在Flip.from里设absolute: true,临时把元素提成绝对定位,动画结束再归位。
目标动画
手机屏幕小、性能也弱,有时候直接阉割动画反而是最优解。用媒体查询定向干掉复杂动效:
@media (max-width: 640px) {
.complex-animation {
animation: none;
opacity: 1;
}
}光用CSS隐藏还不够,JS驱动的动画得彻底销毁。老办法各种监听resize、手动kill动画,容易留内存泄漏。GSAP的matchMedia直接封装了这套逻辑:
let mm = gsap.matchMedia();
mm.add("(min-width: 1024px)", () => {
let tl = gsap.timeline();
tl.to(".box", {x: 200, duration: 1});
return () => {
// 清理代码可写在这,但GSAP会自动还原
};
});当屏幕宽度低于1024px时,里面的动画自动回滚,元素上的内联样式也被清掉。再也不用担心手机还在偷偷跑桌面动画。
更细致的判断还能用prefers-reduced-motion照顾晕动症用户:
mm.add("(prefers-reduced-motion: no-preference)", () => {
// 只有用户没关动画才执行
});滚动触发
用GSAP ScrollTrigger做滚动动画时,屏幕尺寸一变就容易数值错乱。比如一个横向移动的画廊,宽度依赖窗口大小。解决方式:
ScrollTrigger.create({
trigger: ".gallery",
start: "top top",
end: () => `+=${document.querySelector('.gallery').scrollWidth}`,
invalidateOnRefresh: true, // 关键开关
onUpdate: (self) => {
gsap.to(".gallery", {x: -self.progress * (galleryWidth - window.innerWidth)});
}
});invalidateOnRefresh告诉ScrollTrigger:每次窗口尺寸变化后重新计算那些依赖尺寸的值。不然用户横屏转竖屏,动画直接卡在半路上。
另外移动端滚动时地址栏会隐藏,触发放置resize事件。GSAP 3.10后可以忽略这种小波动:
ScrollTrigger.config({
ignoreMobileResize: true
});这样只有真正的大幅度尺寸变化才会刷新,避免动画抽搐。
运动原则
距离越远,跑的时间就得越长。桌面大屏上飞一个元素穿越大半个屏幕,还用0.3秒就太假了,像瞬移。动态调整时长:
let distance = Math.abs(parseFloat(endX) - parseFloat(startX));
let duration = gsap.utils.mapRange(0, 1000, 0.3, 1.2, distance);
gsap.to(".box", {x: endX, duration: duration, ease: "power2.out"});mapRange把距离0~1000像素映射到0.3~1.2秒,超出范围的用clamp截断。
还有数量问题:飘浮的树叶、粒子效果,大屏上稀疏一片看着寒酸,小屏上密密麻麻又卡爆。根据宽度算数量:
let count = Math.floor(window.innerWidth / 200);
count = Math.min(count, 20); // 最多20个
for(let i=0; i<count; i++) {
createFloatingElement();
}宽屏每200像素加一个元素,手机只显示两三个。删除多余元素时别直接remove,给个淡出动画体面退场。
