网页排版从矩形网格开始,最终画面却不一定要规规矩矩地贴在网格上。借助 CSS 变换,我们可以移动、缩放、旋转或倾斜元素,也可以把它放进带透视的三维空间。卡片翻面、悬停反馈、抽屉过渡和几何校正,背后用的都是这套机制。
函数名并不难查,容易出错的是对计算过程的理解。先记住三件事:每个元素都有自己的坐标轴;变换函数按书写顺序连续执行;变换通常只改变绘制结果,不会让元素重新参与普通文档流排版。 后面遇到位置不对、旋转轴奇怪或 3D 层级消失时,我们都会回到这三条逐项检查。

下文所说的“原始位置”,是元素经过布局计算后得到的位置;“视觉位置”则是浏览器应用变换后画出来的位置。这两个位置可能相差很远。
CSS 的二维坐标系和数学课本里的直角坐标系有一处明显区别:x 轴正方向向右,y 轴正方向却向下。你可以用定位来验证这个方向感,top: 20px 会让绝对定位元素向下移动 20 像素。因此,translate(-40px, 30px) 的意思就是向左 40 像素、向下 30 像素。
增加第三个维度后,z 轴会垂直穿过屏幕:正方向靠近观察者,负方向远离观察者。translateZ(80px) 表示把元素向我们这边移动 80 像素。屏幕当然还是平的,浏览器只是通过透视投影画出远近变化。
这里最容易忽略的一点是,坐标轴跟着元素走。元素一旦旋转,它自己的轴也会旋转,后续变换都要在新的参考系里计算:
.card {
transform: rotate(45deg) translateX(120px);
}这段代码先旋转卡片,再执行 translateX(120px)。此时的 x 轴已经倾斜了 45 度,所以卡片会沿斜线移动。若改成 translateX(120px) rotate(45deg),平移时坐标轴还没转,卡片会先沿页面水平方向移动,最终位置自然不同。
角度的正负也要和坐标轴一起看。平面里的 rotate(30deg) 等价于绕 z 轴旋转 30 度,正角度通常是顺时针,负角度通常是逆时针。要理解另外两个轴,可以想象手里拿着一张卡片:rotateX() 像上下掀卡片,rotateY() 则像推门。
transform 如何参与绘制transform 可以取 none,也可以接收一组由空格分隔的函数。浏览器会按书写顺序从左到右处理。前一步既改变元素,也可能改变后一步使用的参考轴和尺度。
.badge {
transform: translateX(120px) rotate(25deg) scale(1.1);
}元素应用变换后,通常会建立新的堆叠上下文。对普通 HTML 元素来说,计算变换时使用的边界一般以边框盒为基础,不包括外边距和轮廓。表格会使用表格包装盒,SVG 还要看自己的对象边界规则。
我们可以把变换理解为“布局完成后的重绘”。卡片放大到两倍,文档流里仍只占原来的空间;卡片平移到另一栏,后面的内容也不会自动补上空位。若目标是重新安排页面结构,应调整 Grid、Flexbox、尺寸、间距或定位。transform 更适合改变视觉位置和形态。
函数列表是一个完整的属性值,其中一个函数写错,整条值都可能失效:
/* rotate() 缺少角度单位,所以整条声明无效 */
.broken {
transform: translateX(80px) scale(1.2) rotate(22);
}同一个元素上后写的 transform 也不会自动接在前一条后面,它会按 CSS 层叠规则覆盖旧值:
.tile { transform: rotate(12deg); }
.tile { transform: scale(1.2); } /* 最终只剩 scale(1.2) */普通的原子行内盒不能可靠地直接应用变换。若要旋转一个 span,可以先把它变成 inline-block:
.tag {
display: inline-block;
transform: rotate(-4deg);
}
下面的实验只改变函数顺序,参数保持不变。切换时可以对照虚线原位框、元素自身的 x 轴和最后的落点。
translate*() 函数与 translate 属性平移就是沿一个或多个坐标轴移动元素。translateX() 和 translateY() 都可以接收长度或百分比。这里的百分比经常被误解:它通常以元素自己的参考盒为基准,并不看父容器有多宽或多高。
.note {
width: 300px;
height: 180px;
transform: translateX(50%) translateY(-10%);
}上面的元素宽 300px、高 180px,所以它会向右移动 150px,向上移动 18px。translate(x, y) 可以同时写两个方向。省略 y 时,浏览器会把它当作 0,因此 translate(2rem) 等价于 translateX(2rem)。
到了三维平移,z 轴的规则更严格。translateZ() 只接受长度,不能写百分比;translate3d(x, y, z) 必须给齐三个值,其中 z 也只能是长度:
.panel {
transform: translate3d(12%, -24px, 80px);
}独立属性 translate 的写法更短:没有函数括号,值之间也不用逗号。一个值表示 x;两个值依次表示 x、y;三个值再加上 z。没有写出的方向按 0 处理。
.toast {
translate: 0 -16px;
}
.layer {
translate: 20% 10% 60px;
}在组件里,独立属性方便把职责拆开。基础样式可以用 translate 控制位置,悬停状态只改 scale,这样不用反复拼接整条 transform。不过浏览器组合这些独立属性时有固定顺序,稍后我们会单独说明。
缩放可以写无单位倍数,也可以写百分比。scaleX(2) 会把元素在 x 轴上的尺寸放大到两倍,scaleY(.5) 会把 y 轴尺寸压到一半。两种写法可以互换:50% 等于 .5,200% 等于 2。
.wide { transform: scaleX(1.5); }
.flat { transform: scale(1.6, .6); }
.same { transform: scale(1); }scale() 只写一个值时,这个值会同时用于 x、y 两轴。这和 translate() 只写一个值时仅处理 x 轴不同。某个轴缩放到 0 后,元素会在这个方向上视觉消失,布局中的盒子却仍然存在。负值还会把轴翻转,例如 scaleX(-1) 可以得到水平镜像。
单独对一个扁平矩形使用 scaleZ(),通常看不出变化,因为矩形本身没有厚度。等到子元素位于不同的 z 位置,或者元素已经绕 x、y 轴旋转,z 轴缩放才会显现出来。使用 scale3d(x, y, z) 时,三个值都要写全。
缩放还会改变后续变换的视觉尺度。看这段代码:
/* 先把坐标系缩小一半,再沿缩小后的轴移动 100px,视觉位移约为 50px */
.item { transform: scale(.5) translateX(100px); }如果交换顺序,写成 translateX(100px) scale(.5),元素会先按原尺度移动 100px,再在新位置缩小。两种写法使用的坐标尺度不同,落点也不同。
独立的 scale 属性可以写一至三个数值,依次控制 x、y、z。只写一个值时,它仍会同时作用于 x、y:
.button:hover {
scale: 1.06;
}rotate()、rotateX()、rotateY() 和 rotateZ() 都接收角度,单位可以是 deg、grad、rad 或 turn。在静止画面中,rotate(45deg) 与 rotateZ(45deg) 的结果相同,因为它们都是绕 z 轴转动。
角度超过一圈也有意义。rotate(437deg) 最后会停在和 rotate(77deg) 相同的方向,但做动画时,前者会多转一整圈。同理,1100deg、340deg 和 -20deg 可能拥有相同终态,抵达终态的路径却不一样。
@keyframes spin-in {
from { rotate: 0deg; }
to { rotate: 740deg; }
}rotate3d(x, y, z, angle) 允许我们自己指定旋转轴。前三个数共同定义一根穿过原点的方向向量,最后一个值才是旋转角度。三个常见坐标轴可以这样写:
/* 分别等价于 rotateX、rotateY、rotateZ */
transform: rotate3d(1, 0, 0, 45deg);
transform: rotate3d(0, 1, 0, 45deg);
transform: rotate3d(0, 0, 1, 45deg);例如,rotate3d(1, 1, 0, 45deg) 表示围绕一根斜向轴旋转一次。它和 rotateX(45deg) rotateY(45deg) 的计算过程不同:前者只有一根轴,后者连续旋转两次,而且第二次旋转使用的是第一次旋转后改变的参考系。
独立的 rotate 属性可以只写角度,也可以把轴名或方向向量写在角度前面:
.door { rotate: y 65deg; }
.chip { rotate: 1 1 0 30deg; }一次 rotate 声明只能描述一根旋转轴,无法表达连续多次旋转。若动作依赖明确的先后顺序,就使用 transform 函数列表。
skewX()、skewY() 与旧式 skew()倾斜会把矩形推成平行四边形:一组边保持平行,另一组边按指定角度偏移。skewX() 沿 x 方向剪切,skewY() 沿 y 方向剪切。CSS 没有 z 轴倾斜,也没有独立的 skew 属性。
.ticket {
transform: skewX(-12deg);
}旋转会保留矩形各边的夹角,倾斜则会改变这些夹角。角度越接近 90 度,图形被拉扯得越明显。即便视觉内容已经溢出,布局边界也不会跟着扩张。
旧代码中还可能出现 skew(ax, ay)。它用两个值描述一次二维矩阵倾斜,与连续执行 skewX(ax) skewY(ay) 不是同一回事。连续调用时,第二步会作用在第一步已经修改的坐标系上。新代码通常分别使用 skewX() 或 skewY(),计算意图更容易看懂。
浏览器最终会把多项变换合并成矩阵。二维 matrix(a, b, c, d, e, f) 必须包含六个用逗号分隔的数。e、f 通常对应最终的 x、y 平移量,其余数值共同记录旋转、缩放和倾斜。
/* 单位矩阵:没有视觉变化 */
.plain { transform: matrix(1, 0, 0, 1, 0, 0); }
/* 末两项把元素向右 40px、向下 20px */
.moved { transform: matrix(1, 0, 0, 1, 40, 20); }三维 matrix3d() 使用 4×4 齐次矩阵,必须写 16 个用逗号分隔的数,并按列序组织。动画工具、图形软件和浏览器计算样式经常会生成这种值,人工阅读和检查却很费力。日常业务代码保留函数链,通常更容易维护。
矩阵可以准确描述终点,却不一定保留原来的运动路径。比如函数链明确写了 rotate(200deg),动画可以顺时针转 200 度;若只留下终态矩阵,插值算法可能选择更短的路径,改为逆时针转 160 度。静态结果相同,过程未必相同。
调试工具显示 matrix(...),只说明浏览器正在展示合并后的计算结果,源代码并没有被改写。动画路径有明确要求时,应在源码中保留含义清楚的变换函数或关键帧。
把 translate、rotate 和 scale 拆成独立属性后,代码排列顺序不再决定计算顺序。无论三条声明怎么放,浏览器都会先做 translate,再做 rotate,最后做 scale;完成这些步骤后,才会处理 transform 里的函数列表。
.mover {
scale: 1.5 1;
rotate: 30deg;
translate: 10rem;
}
/* 上面与下面的几何顺序相同 */
.mover {
transform: translate(10rem) rotate(30deg) scale(1.5, 1);
}固定顺序让组件状态更容易拆分。每个状态只修改自己关心的变换即可:
.button { translate: 0 0; scale: 1; }
.button:hover { translate: 0 -3px; scale: 1.04; }
.button:active { translate: 0 0; scale: .98; }如果设计明确要求先缩放再移动,或者要求连续旋转两次,独立属性就表达不了这类顺序。这时应回到 transform 函数链。
transform-origin 与 transform-box:轴心和参考范围transform-origin 决定变换围绕哪个点发生。它的默认值通常是 50% 50%,也就是元素中心。前两个值分别控制水平和垂直位置,还可以增加第三个值来指定 z 轴上的长度。
.door {
transform-origin: left center;
transform: rotateY(-65deg);
}
.orbit {
transform-origin: 120% 50% -40px;
}水平和垂直原点可以使用关键字、长度或百分比,z 原点只能写长度。长度从参考盒左上角量起,百分比则按参考盒对应方向的尺寸计算。改变原点会明显影响旋转、缩放和倾斜。纯平移只是给所有点加上同一段位移,因此终点不受原点影响。
接下来还要确定这套位置从哪里量起,transform-box 指定的就是参考盒。常用值包括:
border-box:以边框外沿为参考;content-box:以内容盒为参考;fill-box:以 SVG 图形的对象边界为参考;stroke-box:连同 SVG 描边边界一起参考;view-box:以最近的 SVG 视口为参考。SVG 最能看出参考盒的影响。transform-origin: 0 0 配合 view-box 时,旋转轴心可能落在整个 SVG 视口左上角;换成 fill-box 后,同一个 0 0 会落在图形自身边界的左上角。数值没变,参照范围变了,旋转轨迹也会随之改变。

下面可以直接拖动原点,并在边框盒与内容盒之间切换。红点标出的就是当前实际轴心。
透视负责把 z 轴上的远近投影到二维屏幕。距离越小,近大远小的差异越明显;距离越大,画面越平缓。当距离非常大时,结果会接近没有透视的正交投影。这里必须使用大于 0 的长度值。
perspective() 是一个变换函数,只作用于当前元素。它在函数列表中的位置会影响结果,通常放在需要透视的 3D 变换前面:
.card {
transform: perspective(700px) rotateY(45deg);
}如果把 perspective() 放到 rotateY() 后面,它处理的就是已经旋转过的参考系,画面可能和预想不同。若一组兄弟元素各自使用 perspective(),每个元素都会建立自己的观察空间,看起来像分别拍摄后再拼在一起。
要让一组元素处在同一空间里,可以把 perspective 属性写在它们的父容器上:
.scene {
perspective: 800px;
}
.scene > .card {
transform: rotateY(35deg) translateZ(40px);
}这样,子元素会共享同一个视点和消失点,几张卡片看起来像真的摆在同一空间。perspective 本身不会继承,但父容器上的值会参与子元素的三维投影。
perspective-origin 用来移动父容器中的消失点,默认位置是 50% 50%。设置成 20% 30% 后,观察位置会偏向左上方。这个属性要和透视配合使用;如果 perspective 仍是 none,只改消失点不会改变画面。
.scene {
perspective: 850px;
perspective-origin: 20% 30%;
}transform-style:保留深度或压成平面父元素的 transform-style 默认值是 flat。即使子元素使用了 translateZ(),浏览器也会先把这些子元素合成到父元素所在的平面上,像一张已经压平的贴图。父元素随后旋转时,子元素原本的前后关系不会继续保留。
.scene {
perspective: 900px;
}
.deck {
transform-style: preserve-3d;
rotate: y 25deg;
}
.front { translate: 0 0 60px; }
.back { translate: 0 0 -60px; }设置 preserve-3d 后,后代可以继续留在父元素的三维上下文中。这个值不会继承。场景嵌套了多层容器时,要检查每个负责传递深度的中间层,必要时逐层声明。
还有一些合成效果会要求浏览器先压平后代,此时 preserve-3d 的使用值会变成 flat。如果场景突然没有深度了,可以检查 3D 容器上的 overflow、filter、clip、clip-path、mask-image、mask-border-source、mix-blend-mode 和 isolation 是否使用了非默认值。常见的安全值有 overflow: visible、filter: none、clip-path: none、mix-blend-mode: normal 与 isolation: auto。

backface-visibility:决定背面是否绘制元素绕 x 轴或 y 轴转过 90 度后,朝向观察者的就变成了背面。backface-visibility: visible 会继续绘制这一面,文字通常会呈现镜像效果;设为 hidden 后,背面朝向观察者时,浏览器不会绘制该元素。
制作双面卡片时,可以把正反两面绝对定位到同一位置,并让它们都隐藏背面。反面先旋转 180 度,交互发生时再旋转整个卡片容器:
.scene { perspective: 900px; }
.card {
position: relative;
transform-style: preserve-3d;
transition: transform .6s;
}
.face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.back
backface-visibility 不会继承,也不能做动画。它只回答一个问题:背面朝向观察者时要不要画出来。透视、三维空间和旋转都要由其他属性负责。
下面的实验把卡片翻面、背面显示和三维保留拆成三个开关。你可以逐个关闭,观察每项设置具体负责什么。
排查变换问题时,按计算过程往下走通常最省时间。先确认元素是不是可变换盒,再检查整条声明有没有语法错误。二维结果仍不对,就核对函数顺序、原点和参考盒;3D 场景还要继续检查透视、preserve-3d、可能触发扁平化的属性以及背面可见性。
下面这些情况很常见:
transform 解决本应由布局处理的间距,导致视觉位置与点击/阅读顺序不一致;transform;rotate3d(x,y,z,a) 当成三次连续旋转;perspective(),却期待它们共享一个消失点;preserve-3d,忘记中间容器仍可能压平后代;overflow: hidden 或滤镜后,忽略场景可能被强制扁平化。从性能看,transform 和 opacity 常能在合成阶段完成,因此适合用于动画。但图层并不是越多越好,will-change: transform 也不该长期挂在大量元素上。只有性能分析确认它能解决问题、而且动画即将发生时再使用;动画结束后应及时移除。
还要为希望减少动态效果的用户提供更安静的版本:
@media (prefers-reduced-motion: reduce) {
.card,
.button {
transition-duration: 0.01ms;
animation-iteration-count: 1;
}
}翻面组件若只依赖悬停,键盘和触屏用户就很难操作。可以提供可聚焦按钮、清楚的操作文本,以及正确的 aria-expanded 或状态提示。还要记住,视觉上的翻面不会自动改变 DOM 阅读顺序。隐藏内容是否应被辅助技术读取,需要跟随组件状态明确管理。
实际写代码时,我们可以按下面五步组织变换:
最后再提醒三个容易混淆的点:函数列表按书写顺序执行,独立属性按固定顺序组合,矩阵主要记录终态。源码优先保留容易阅读和验证的函数写法;只有工具输出或精确几何计算确实需要时,再直接处理矩阵。