阅读视图

发现新文章,点击刷新页面。

CSS 技巧:CSS 单位使用指南

写完《现代 CSS 中的相对单位》这节课之后,我原本以为大家会带着一种“原来如此”的轻松感继续往下写代码。但很快,私下就有不同的同学问:为什么这里不用 px ?什么时候该用 remvw0% 到底有什么区别?移动端布局什么该用 rem 还是 vw ?这些问题不仅在一个地方出现,而是在不同平台、不同时间被反复提起。

于是,我决定单独用一节课,把这些零散的问题集中起来,好好聊一聊。我们不再孤立地看某一个单位,而是把 pxemrem% 、视窗单位(如 vwvh)、容器查询单位(如 cqwcqh)以及 exchlh 放在同一个语境下,重新理解它们的作用,以及更重要的——如何做出选择。

CSS 单位选择的本质:不是用什么,而是跟随谁变化

在实际开发中,很多人一开始都会本能地选择 px。它足够简单、直接,几乎不需要额外思考——设计稿量多少就写多少,浏览器呈现出来的结果也高度一致。这种“所见即所得”的确定性,在项目初期确实非常有吸引力。

但随着项目逐渐复杂,这种看似稳妥的选择,往往会开始暴露出问题。页面在不同设备上看起来似乎“差不多”,却总差那么一点协调感;字体系统变得难以统一,局部调整容易牵一发动全身;为了适配各种尺寸,不得不引入越来越多的媒体查询;而组件一旦脱离原有上下文,复用时也常常出现不可预期的表现。

很多人会因此怀疑,是不是一开始就不应该使用 px 。但从结果来看,问题并不在于某一个具体单位,而在于我们没有在一开始建立一套清晰的尺寸关系

这正是这节课真正要解决的核心问题。CSS 单位表面上是在描述“长度”,但它们更本质的作用,其实是在定义一种关系——元素的尺寸,应该跟随变化

当你写下一个值时,本质上是在做一次“绑定”。使用 px ,意味着它几乎不跟随外部环境变化;使用 % ,它会依赖父元素;使用 em ,它会响应当前元素的字体大小(font-size);使用 rem ,则是跟随根元素(html);视窗单位(如 vwvh)绑定的是浏览器的视窗,而 cqwcqh 这样的容器查询单位,则让元素直接跟随其所在的父容器。

也就是说,你并不是在选择“用哪种单位更好”,而是在决定这个元素应该“听谁的”。是跟随屏幕变化,还是跟随父级容器?是依赖全局排版系统,还是保持自身的稳定?一旦换一个角度去看,很多原本让人纠结的问题,其实会自然消失。

你会开始意识到,px 并不是错误,它只是选择了不响应变化;rem 也不是万能方案,它只是把变化集中在一个全局入口;vw 看起来很灵活,但有时会让组件失去边界感;而容器查询单位,则是在回答一个更现代的问题——组件是否可以只关心自己所在的空间,而不是整个页面。

接下来,我们不会从定义出发逐个讲解每个单位,而是先建立一套“尺寸关系模型”,再去分析每种单位适合绑定的对象,最后总结出一套可以直接应用在项目中的选择策略。目标不是让你记住所有单位的细节,而是让你在任何场景下,都能快速判断——这里的尺寸,究竟应该跟随谁变化

CSS 单位的尺寸关系模型

如果把前面所有零散的讨论收拢在一起,其实可以得到一个非常关键的结论:

CSS 单位并不是一堆彼此独立的“语法选项”,它们本质上构成了一整套尺寸依赖关系系统

一旦从这个角度去理解,就可以把所有单位放进一个统一的框架中来看待,也就是所谓的“CSS 单位的尺寸关系模型”。

这个模型的核心可以用一句话概括:每一个尺寸,本质上都是依附在某个参照物上的结果。 你写下的从来不是一个孤立的数值,而是一种“绑定关系”。也就是说,当你在写 CSS 时,你真正做的事情并不是定义一个长度,而是在决定这个长度应该“跟谁产生关系”。

基于这个视角,我们就可以把所有常见单位重新整理一遍。不再按语法分类,而是按照它们“依附谁”来划分。这样整理之后,会得到一个非常清晰的结构——一个由不同“关系层级”组成的尺寸模型

绝对关系:不跟随任何对象

在所有尺寸关系中,“绝对关系”是容易理解的一类,它几乎不依附于任何外部对象。典型代表就是 px 。当你使用 px 时,本质上传达的是一种明确的意图:这个尺寸应当保持稳定,不随着环境变化而改变。

这种特性让 px 在很多场景下非常有价值。比如边框、阴影、精细对齐等细节控制,都需要高度确定性和可预测性,这正是 px 的优势所在。它提供了一种接近“所见即所得”的体验,让你可以精确地掌握视觉结果。

但问题在于,一旦把这种“固定性”扩展到布局层面,比如用在容器尺寸、间距系统或整体排版上,就会开始削弱页面的弹性。页面难以自然适配不同设备,响应能力变差,后期往往需要额外的补丁来弥补。

所以,与其把 px 简单理解为“绝对单位”,不如换一个更本质的视角来看——它其实是一种主动拒绝建立关系的选择

全局关系:跟随根元素

在“在局关系”这一类中,所有尺寸都会统一依附于同一个源头——根元素(html)。最典型的代表就是 rem 。当你使用 rem 时,本质上传达的是一种明确的选择:这个尺寸不由局部环境决定,而是完全跟随全局系统

这种机制非常适合用于构建设计系统。无论是字体层级、间距体系,还是组件的基础尺寸,都可以基于 rem 来建立。一旦调整根元素的字号(font-size),整个页面就会按照既定比例整体缩放,从而实现一致且可控的响应效果。

不过,这种“全局绑定”也有一个前提:你必须建立清晰且合理的全局规则。如果没有这一步,rem 并不会减少复杂度,只是把原本分散在各处的问题,集中转移到了全局层面。换句话说,rem 并不是简化问题的工具,而是放大设计系统价值的工具。

局部关系:跟随当前上下文

“局部关系”这一类单位,强调的是尺寸与当前上下文之间的依赖关系,而不是与整个页面或全局系统的绑定。典型代表是 em% 。其中,em 相对于当前元素的字体大小(font-size),而 % 通常相对于父元素的尺寸计算。需要知道的是,% 是一个较为复杂的单位,它应用在不同属性值时,依附的参照物是不同的。

当你使用这类单位时,其实是在表达这样一种意图:这个元素的大小,不由全局决定,而是由它所处的环境来决定。换句话说,它就是“就地适应”的。

这种特性在组件内部尤其有用。例如按钮、卡片这类 UI 元素,其内边距、间距、子元素尺寸等,都可以随着组件自身的变化而自然缩放,从而形成更具弹性的结构。

不过,这种“局部依赖”也带来一个隐患:它是可以层层嵌套的。一旦组件结构变得复杂,多层 em% 叠加之后,最终的计算结果往往不再直观,甚至难以追踪。因此,在使用这类单位时,需要对层级关系保持足够的控制,避免无意中引入过深依赖链。

环境关系:跟随视口

“环境关系”这一类单位,直接把尺寸绑定到浏览器视口,也就是屏幕本身。典型代表是 vwvh ,以及 vminvmax 等。使用这些单位时,元素的尺寸应当随着屏幕大小按比例变化——屏幕多大,它就多大。

这种能力在很多场景中非常有用。比如全屏布局(如首屏视觉区域)、流式字号,以及一些强依赖视觉比例的设计,都可以通过视口单位获得非常自然的响应效果。

不过,一旦把这类单位用在组件内部,就容易引发问题。因为此时组件的尺寸不再由自身或其容器决定,而是直接被整个页面环境“接管”。结果就是,组件失去了应有的独立性,在不同布局或上下文中变得难以控制和复用。

容器关系:跟随父容器

“容器关系”代表的是一种更现代的尺寸绑定方式——元素不再依赖整个视口,而是直接依附于自身所在的父容器。典型单位是 cqwcqh 等容器查询单位。

当你使用这类单位时,这个元素的尺寸,不再由屏幕决定,而是由它当前所处的空间来决定。也就是说,它只关心“自己有多少可用空间”,而不是整个页面有多大。

这正好解决了传统响应式设计中一个核心限制——媒体查询只能基于视口进行判断,但在实际开发中,组件更关心的往往是自身容器的尺寸,而不是屏幕尺寸本身。

容器单位的出现,让响应式能力从“页面级”下沉到了“组件级”。组件不再依赖外部布局或全局断点,就可以根据自身环境自动调整,从而真正具备独立、自适应的能力。这也是现代 CSS 组件化设计中非常关键的一步。

内容关系:跟随文本与排版度量

“内容关系”这一类单位,关注的已不再是布局结构,而是文本本身的度量。典型代表包括 chexlhrlh 。与前几类不同,它们不依赖视口、容器或父元素,而是直接绑定到字体和排版系统之上。

当你使用这些单位时,其实是在表达一种更偏向内容驱动的设计思路。尺寸应当由文本本身来决定,而不是由外部布局强加。比如,ch 基于字符宽度,常用于控制理想的阅读行长;ex 反映字体的 x-heightlhrlh 则直接与行高(line-height)相关,用来建立稳定的垂直节奏。

这种方式的价值在于,它把“排版经验”转化成了“系统规则”。例如,用 60ch 控制段落宽度,可以自然得到更舒适的阅读长度;用 1lh 控制段落或模块之间的间距,可以让整体节奏始终保持 一致,而不需要反复微调具体数值。

从更高层来看,这类单位所代表的,是一种设计理念的转变:不是让内容去适配布局,而是让布局围绕内容生长

比例关系:无单位

这一类并不是在描述“跟随谁变化”,而是在定义变化本身。与前面所有单位不同,它不关心依附对象,而是直接规定变化的比例,因此,可以把它理解为一种“比例关系”。典型的例子包括:

line-height: 1.5;
flex: 1;
opacity: 0.8;

这些值有一个共同特点:它们表达的不是一个具体长度,而是一个倍数关系。换句话说,它们关注的不是“多大”,而是“多少倍”。以最常见的 line-height: 1.5 为例:

p {
    font-size: 16px;
    line-height: 1.5;
}

这里的 1.5 实际含义是:行高等于当前字体大小的 1.5 倍。如果字体大小从 16px 变成 20px,行高会自动变成 30px

关键在于,这种关系是“内嵌”的,而不是“引用”的。它不像 em 那样依赖字体作为单位,也不像 rem 依赖根元素,而是直接把比例规则写进属性本身。这种表达方式更直接,也更稳定。

正因为如此,在很多场景中,无单位数值反而是更推荐的写法,尤其是在排版中。相比固定的 24px,或者可能受嵌套影响的 1.5emline-height: 1.5 不依赖具体单位,不会产生层级累积问题,还能随着字体变化自动调整。同时,它的语义也更清晰——表达的是“阅读密度”,而不是一个具体尺寸。

这种“比例关系”其实在 CSS 中无处不在,并不仅仅局限于排版。在 Flex 布局中:

.item {
    flex: 1;
}

表达的是空间分配的比例,而不是具体宽度。

在 Grid 中,虽然 fr 看起来像单位,但本质上同样是一种比例系统:

grid-template-columns: 1fr 2fr;

表示的是 1:2 的空间分配关系。再比如透明度、变换等属性:

opacity: 0.5;
scale: 1.2;

同样是在描述相对变化,而不是绝对数值。

从这个角度来看,无单位数值其实承担的是另一种角色:它不负责“绑定关系”,而是负责在既定关系中,定义变化的幅度

在理解了前面的所有关系之后,其实还可以再往前走一步,把整个模型进一步抽象。换一个更简洁的视角来看,CSS 中的所有尺寸,本质上都在回答两个问题:它是否需要变化?如果需要变化,它应该跟随谁变化。

第一个问题是在区分“固定”和“响应”。有些尺寸需要保持稳定,比如边框或精细对齐,这时选择的是“不变化”;而有些尺寸则需要随着环境调整,这时就进入“响应”的范畴。第二个问题则是在确定依附对象——是跟随全局系统、局部上下文、视口环境、容器空间,还是内容本身。

一旦用这种方式思考,单位选择就不再是一个依赖经验或记忆的过程,而是一个可以被推导的决策过程。你不再需要记住“某种场景用某种单位”,而是先明确关系,再自然得出答案。

这也正是“尺寸关系模型”真正重要的地方。很多开发中的问题,并不是单纯因为“单位选错了”,而是关系选错了:在应该使用全局关系的地方使用了局部关系,在应该依赖容器的地方却绑定了视口,在应该围绕内容排版的地方却用了固定值。结果就是,系统逐渐失去一致性,适配变得困难,维护成本不断增加。

而这个模型的价值,在于提供了一个统一的判断框架:先决定关系,再选择单位。一旦顺序颠倒——先选单位,再试图去适配各种场景——问题往往就会不断出现。

最佳使用场景

从这里开始,CSS 单位就不再是一组零散的工具,而是一整套可以被设计、被推理、也可以被复用的系统。你不再是在零碎地选择某一个单位,而是在基于一套清晰的关系模型,去构建整个页面的尺寸逻辑。

接下来,更关键的一步,是把这套模型真正落到实际项目中。也就是说,在具体场景里,你到底该使用哪一类单位。与其去记忆“某种场景对应某种单位”,不如换一种更稳定的思考方式——从“关系”出发,反推出“选择”。一旦你明确了这个元素应该跟随谁变化,单位本身几乎是自然浮现的。

因此,接下来的内容,我们会基于这套“尺寸关系模型”,按顺序逐一拆解每一类单位在真实项目中的最佳使用场景。重点不在于记住规则,而在于理解背后的逻辑,并能够在不同情境中灵活应用。

当你理解到这一层时,CSS 单位这件事就不再是一个依赖经验的选择题,而是一个可以建模、可以推导,甚至可以系统化设计的能力。这一步,基本已经进入“设计系统思维”的范畴了。

px :用于“需在稳定、不参与响应”的细节

px 最适合的使用场景,其实并不在布局层面,而是在那些需要稳定、不参与响应变化的细节上。换句话说,当一个尺寸的变化不会带来收益,甚至可能破坏视觉一致性时,px 往往是最合理的选择。

典型的例子包括细边框(如 1px0.5px)、图标对齐的微调、阴影与描边、分割线,以及一些小范围的视觉校正。这些细节的共同点在于:它们依赖精确的像素控制,一旦随着环境变化而缩放,反而容易产生模糊、偏移或不协调的视觉效果

从这个角度来看,在这些场景中使用 px,反而是一种更“响应式”的选择——因为你在有意识地避免不必要的变化,让关键细节保持稳定。

但需要注意的是,这种优势并不适用于布局层面。如果把 px 用作主要的布局单位,比如容器宽度、模块间距或字体尺寸,一旦页面进入多端适配阶段,这些“固定值”就会迅速变成负担,限制整体的弹性和可扩展性。

rem:用于“全局一致”的系统级尺寸

rem 最适合的使用场景,是构建设计系统中的“全局一致”尺度。它的核心价值在于,把所有尺寸统一绑定到根元素,从而让整个页面在同一个规则下运作。

在实际项目中,rem 常用于定义全局字体体系、统一的间距系统,以及组件的基础尺寸(例如 paddingmargingap)。这些场景有一个共同特点:它们需要在整个页面中保持一致,并且能够随着整体策略一起缩放

例如,你可以基于 rem 定义一套间距系统:

:root {
    --space-1: 0.5rem;
    --space-2: 1rem;
    --space-3: 2rem;
}

当根元素的字号发生变化时,这些间距会自动按比例调整,而不需要逐个修改组件。这种方式可以极大地降低维护成本,同时保证视觉上的一致性。

不过需要注意的是,rem 更适合用于“系统层”,而不是“局部例外”。如果某个组件需要根据自身环境动态变化,比如根据容器或上下文自适应,那么继续使用 rem 反而会让它变得僵硬。换句话说,rem 擅长统一规则,但并不擅长处理局部差异。

em% :用于“组件内部的自适应关系”

em% 这一类单位,最适合用于构建组件内部的自适应关系。它们的特点是依赖当前上下文,而不是全局系统,因此非常适合用来描述组件内部各个元素之间的比例关系。

例如,一个按钮组件可以这样定义:

.button {
    font-size: 1rem;
    padding: 0.5em 1em;
}

这里的 em 实际上传达的是一种关系:按钮的内边距应该随着字体大小变化。也就是说,组件内部的空间是“绑定”在自身排版之上的,而不是写死为某个固定值。

类似的使用场景还有很多,比如图标与文字之间的比例关系(使用 em),子元素宽度占父元素的比例(使用 %),以及卡片内部结构中常见的 100% 宽度元素等。这些都属于在组件内部建立相对关系的典型用法。

这类单位的核心价值在于,让组件具备“自我缩放”的能力。当组件被放入不同的上下文中,比如更大的容器、不同的字号环境,或者不同的布局结构时,它可以自然适配,而不需要额外调整具体数值。

不过需要注意的是,这种依赖关系是可以层层叠加的。一旦嵌套层级过深,em 的计算路径就会变得复杂,最终结果也不再直观。因此,在复杂结构中使用时,需要有意识地控制层级,避免关系链失控。

视窗单位:用于“与屏幕强绑定”的布局或视觉

vwvh 这类视窗单位,最适合用于那些与屏幕强绑定的布局或视觉设计。它们直接以浏览器视口为参照,因此表达的是一种非常明确的关系:尺寸应该随着屏幕变化,而不是由容器或内容决定。

在实际项目中,这类单位常见于全屏布局、流式字号,以及一些依赖屏幕比例的视觉效果。例如:

.hero {
    height: 100vh;
}

或者:

h1 {
  font-size: clamp(2rem, 5vw, 4rem);
}

这些用法的共同点在于:它们让元素的尺寸直接响应视口变化,从而在不同设备上保持一种整体的视觉比例

不过需要注意的是,这种“直接绑定屏幕”的能力,并不适合用在组件内部。一旦在组件中使用 vwvh,组件的尺寸就会脱离自身上下文,被整个页面环境所控制。这会导致组件难以复用,也更难在不同布局中保持稳定表现。因此,视口单位更适合作为页面级或视觉级的工具,而不是组件级的默认选择。

窗器单位:用于“真正可复用的响应式组件”

cqwcqh 等容器查询单位,最核心的使用场景是构建真正可复用的响应式组件。与视口单位不同,它们并不依赖整个页面,而是直接绑定到组件所在的父容器。

例如一个卡片组件:

.card {
    width: 100%;
    padding: 5cqw;
}

这里表达的是一种非常清晰的关系:组件的尺寸不由屏幕决定,而是由它当前所处的容器空间决定。也就是说,组件只关心“自己有多少可用空间”,而不关心整个页面的大小。

这种能力在很多场景中尤为重要。比如同一个组件需要出现在不同布局中(侧边栏、主内容区或网格中),或者组件需要根据可用空间自动调整结构,又或者希望减少对媒体查询的依赖。这些问题,用传统的视口响应方式往往很难优雅解决,而容器单位则可以直接应对。

从更高层来看,容器查询单位的意义在于,让组件具备一种“环境感知能力”,但这个环境是局部的,而不是全局的。组件不再依赖页面断点,而是根据自身所处的上下文自我调整。这也可以说是现代 CSS 从页面级响应式迈向组件级响应式的关键一步。

chlh 等:用于“排版与阅读体验驱动”的设计

chlh 等这类单位,最适合用于那些以排版和阅读体验为核心的内容区域。与前面侧重布局的单位不同,它们直接绑定到文本本身的度量,因此更适合解决“读起来是否舒服”的问题。

典型的应用场景包括:控制文章正文的宽度(例如 max-width: 60ch)、设置段落之间的间距(如 margin-bottom: 1lh),以及建立整体的垂直节奏。这些场景有一个共同点:尺寸不应该由布局决定,而应该由内容本身来驱动

例如:

.article {
    max-width: 65ch;
    line-height: 1.6;
}

通过 ch 控制行长,可以直接得到更理想的阅读宽度,而不需要反复尝试具体数值。再比如:

p {
    margin-bottom: 1lh;
}

使用 lh 来定义间距,可以让段落之间始终与当前的文本节奏保持一致。

这类单位的真正价值,在于它们把原本依赖经验的排版决策,转化为可以复用的系统规则。你不再需要不断“微调数值”,而是可以通过单位本身,让设计自然成立。

无单位值

无单位数值最适合用于那些本质上是在表达“比例关系”,而不是具体尺寸的场景。与其他单位不同,它不依附于某个参考系,而是直接定义一种倍数规则,因此特别适合用来描述变化幅度、分配关系或节奏控制。

在实际开发中,这类写法广泛存在于排版、布局和视觉效果中。例如在排版中:

p {
    line-height: 1.6;
}

这里的 1.6 表达的是行高与字体大小之间的比例关系,而不是一个固定高度。这样做的好处是,无论字体如何变化,行高都会自动按比例调整,从而保持稳定的阅读节奏。这类用法的共同点在于:它们关注的不是尺寸本身,而是尺寸之间的关系。

无单位数值的核心价值在于,它把设计中的“比例”和“节奏”直接表达出来,而不会受到单位或上下文嵌套的干扰。相比使用固定值或依赖单位的写法,它通常更加稳定、可预测,也更贴近设计本意。

从更高层来看,这类写法实际上是在把“经验规则”转化为“数学关系”。你不再是在不断调整一个个具体数值,而是在定义一套可以自动生效的比例系统。这也是它在现代 CSS 中越来越重要的原因。

如果把前面所有使用场景再压缩成一条清晰的决策路径,其实可以用一个非常简单的思考顺序来判断:首先,这个尺寸是否需要变化?如果答案是不需要,那么直接使用 px,让它保持稳定即可。

如果这个尺寸需要变化,接下来要问的就是——它应该跟随谁变化。是跟随全局系统(rem),还是组件内部(em%),是跟随屏幕(vwvh),还是跟随容器(cqwcqh),又或者是跟随内容本身(chlh)。当这个问题有了明确答案,单位的选择几乎是自然得出的结果。

当你用这种方式做决策时,会慢慢意识到一件事:你不再是在“选单位”,而是在设计一套尺寸关系。一旦关系是清晰的,代码本身就会变得更加稳定、可预测,同时也更容易维护。这才是 CSS 单位真正的使用方式。

不过,到这里还只是第一步。如果你已经接受了“尺寸关系模型”的视角,那么接下来一个更贴近真实开发的问题就会浮现出来:在实际项目中,我们几乎不会只使用一种单位——那不同类型的单位应该如何组合使用?

这一步,才是真正体现 CSS 能力的地方。因为一旦进入“混合使用”,你所做的就不再只是选择某一种关系,而是在有意识地组合多种关系,让它们在同一个系统中协同工作。

混合使用的本质:叠加多个“跟随逻辑”

在讨论“混合使用”之前,需要先把一件事说清楚:混合单位并不是随意拼凑,而是在同一个元素上,有意识地叠加不同层级的“依附关系” 。每一种单位,都在负责一部分逻辑,而不是彼此冲突。

举个最简单的例子:

.card {
    padding: 1rem 2em;
}

这段代码里,其实同时存在两种关系。1rem 绑定的是全局系统,用来保证整体的一致性;而 2em 则跟随组件内部的字体大小,用来维持局部的协调性。

从结果来看,这并不是混乱,而是一种分工:全局负责统一规则,局部负责自适应细节。正是这种“关系的叠加”,让组件既能融入整体系统,又能在自身内部保持灵活性。

最常见的组合:全局 + 局部(rem + em

这是最推荐、也最稳定的一种组合方式:让不同层级的关系各司其职,而不是互相干扰。一个常见且有效的模式是——用 rem 定义基础尺寸,用 em 来做组件内部的比例调整。

例如一个按钮组件:

.button {
    font-size: 1rem;       /* 全局控制 */
    padding: 0.5em 1.2em;  /* 局部跟随 */
    border-radius: 0.5rem; /* 系统统一 */
}

这里的结构其实非常清晰。按钮的整体尺寸(如字体大小、圆角)是绑定在全局系统上的,因此可以保持一致性;而内部的间距(padding)则依赖自身字体,从而能够随着组件大小自然缩放。

这种组合方式的核心在于分层:全局负责统一规则,局部负责内部协调。最终的效果是,组件既能融入整体设计系统,又能在自身范围内保持灵活性——既统一,又不僵硬。

布局 + 内容:%fr + chlh

在内容型页面(比如文章或文档)中,一种非常高效且稳定的组合方式是:用布局单位控制结构,用内容单位控制阅读体验。也就是说,先解决“内容放在哪里”,再解决“内容读起来是否舒服”。

例如:

.layout {
    display: grid;
    grid-template-columns: 1fr min(65ch, 100%);
}

在这个结构中,1fr 负责的是整体布局的空间分配,让页面具备弹性;而 65ch 则用来限制文本的最大行长,从而保证阅读的舒适度。两者关注的层面完全不同,却可以自然协同。

再比如段落间距的处理:

.article p {
    margin-bottom: 1lh;
}

这里使用 lh 来控制间距,使其直接跟随文本节奏变化,而不是依赖固定数值。

从整体来看,这种组合的核心在于分工明确:布局单位决定“块在哪里、占多少空间”,而内容单位决定“读起来是否舒适” 。当两者各自负责自己的问题时,页面既能保持结构上的灵活性,又能在阅读体验上更加稳定自然。

响应式组合:rem + vw

这是现代响应式设计中非常常见、也非常实用的一种组合方式。它的核心思路可以概括为一句话:在“稳定”和“流动”之间找到平衡

例如:

h1 {
    font-size: clamp(2rem, 5vw, 4rem);
}

这段代码实际上融合了多层关系。2rem 作为最小值,提供一个基于全局系统的稳定下限;5vw 作为中间值,让字体能够随着视口变化而动态调整;而 4rem 则作为最大值,防止尺寸在大屏上无限增长而失控。

从本质上来看,这种写法并不是简单地混用单位,而是在构建一套“有边界的响应规则”:允许尺寸根据环境变化,但同时为这种变化设定清晰的上下限。这样既能获得流动性的优势,又能避免不可控带来的问题。

这种模式的价值在于,它让响应式不再是“要么固定,要么完全流动”的二选一,而是变成一种可控、可设计的连续变化过程。

组件级响应:容器单位 + rem

当你开始使用容器查询单位时,一种非常自然、也非常实用的组合方式就会出现:让容器决定结构,用 rem 维持系统一致性

例如:

.card {
    padding: 2rem;
    gap: 2cqw;
}

这里其实是两种关系在协同工作。rem 用来定义基础间距,确保组件始终遵循整体设计系统;而 cqw 则根据容器宽度进行微调,让组件在不同空间下都能做出合适的调整。

这种组合的关键在于分工清晰:全局系统负责“统一语言”,容器关系负责“适应环境” 。最终的结果是,组件既能够在不同容器中灵活变化,又不会偏离整体设计风格,从而同时具备一致性和适应性。

固定 + 弹性:px + 其他单位

在实际开发中,“完全响应式”并不总是目标。有些细节本身就需要保持稳定,比如 1px 的边框、精细的阴影、或者分隔线。这些元素一旦随着环境变化,反而容易破坏视觉的清晰度和一致性。

因此,一种非常常见且合理的做法是,把不同类型的尺寸分开处理。例如:

.card {
    padding: 1rem;
    border: 1px solid #ddd;
}

这里的 rem 用来控制整体结构,让组件能够随着全局系统调整;而 px 则用来锁定细节,确保边框始终保持清晰和稳定。

这种组合方式的核心,其实是一种取舍:让该变化的部分具备弹性,让不该变化的细节保持稳定。当这两者各自发挥作用时,页面既有响应能力,又不会失去精致度,这是一种非常健康且实用的平衡。

calc():显式组合关系

当你需要更精细地控制多种尺寸关系时,可以借助 calc() 把这些关系“写出来”。它的作用,不只是做简单计算,而是把原本隐含在设计中的逻辑,转化为清晰可读的表达。

例如:

.container {
    padding: calc(1rem + 2vw);
}

这里实际上是在组合两种关系:1rem 提供一个稳定的基础间距,而 2vw 让间距可以随着屏幕略微放大。也就是说,这个间距既有全局一致性,又具备一定的响应能力。

再比如:

.box {
    height: calc(100vh - 4rem);
}

这里表达的是另一种常见逻辑:用视口高度作为整体空间,再减去一个固定的导航高度,从而得到实际可用区域。

从本质上看,calc() 的价值在于,它让不同关系之间的组合变得明确且可控。你不再需要在脑中“估算”这些关系,而是可以直接用数学形式把它们表达出来,从而让代码本身就成为设计逻辑的一部分。

当你逐渐熟悉这些单位的组合方式之后,会开始看到一个更高层的模式——不同单位,其实在解决不同“层级”的问题。换句话说,它们并不是随意选择的工具,而是在各自的层面上承担不同职责。

例如,px 更偏向视觉细节层,用来处理那些不应该变化的精细部分;rem 属于设计系统层,负责全局的一致性;em% 则工作在组件内部,用于建立局部的自适应关系;vwvh 面向页面环境,与屏幕尺寸直接相关;cqw 这样的容器单位属于组件环境层,让组件根据自身空间变化;而 chlh 则作用于内容层,用来优化排版和阅读体验。

当这些层级被理清之后,一个非常重要的原则也会变得清晰:在混合使用单位时,最容易出问题的情况,是多个单位在“争夺同一个控制权”。比如,一个尺寸既想跟随视口(vw),又想跟随容器(cqw);或者字体既由 rem 控制,又在嵌套中被 em 不断放大。这种情况下,结果往往会变得不可预测,也更难维护。

为了避免这种问题,可以用一个非常简单但实用的判断方式:在写下任何一个尺寸之前,先问自己一句——这个值,主要由谁来决定? 如果答案是唯一的,那么直接选择对应的单位即可;如果答案涉及多个因素,那么就需要通过组合(例如 clamp()calc(),或者分层设计)来明确各自的作用边界。

当你开始有意识地“混合关系”,而不是简单地“混合单位”时,你会发现一个明显的变化:CSS 不再是一个依赖不断试错的过程,而是变成了一套可以被设计、被推导的系统。这一步,其实就是从“会写 CSS”,走向“会设计 CSS”的关键转变。

小结

当你把这些内容全部走完,再回头看最开始的问题——“到底该用 pxrem,还是 vw?”——其实已经不那么重要了。

因为你已经不再依赖某一个“正确答案”,而是拥有了一套可以自己推导答案的思考方式。

你会开始从“关系”出发,而不是从“单位”出发;先判断这个尺寸应该跟随谁,再去选择合适的表达方式。很多过去需要反复试错的问题,也会在这个过程中自然消失。

从更长远来看,这种思维方式的价值,并不仅仅局限在 CSS 单位上。它本质上是在训练你用一种更结构化的方式去理解界面:把页面拆成不同层级,把尺寸拆成不同关系,再通过组合让它们协同工作。

当你做到这一点时,CSS 就不再只是“写样式”,而是在构建一套有逻辑的系统。

而这,也正是从“能实现设计”,走向“能设计系统”的关键一步。

现代 CSS 的新力量

如果你拆开一个稍微复杂一点的 Web 应用,很容易发现一个熟悉的现象。在还没写任何业务代码之前,就已经引入了一堆 JavaScript 库,只是为了实现一些基础的 UI 行为,比如提示框定位、滚动动画、弹窗管理,甚至一个简单的下拉选择框。而现在,这种情况正在发生改变——越来越多这样的能力,开始被 CSS 接管。

以提示框(Tooltip)和弹窗(Popover)为例,过去通常需要依赖像 Floating UI Popper 这样的库来计算位置、处理溢出、实现自动翻转。这类问题本质上是“布局问题”,却一直由 JavaScript 来解决。如今,CSS 锚点定位以及原生 Popover API 已经可以直接完成这些工作,浏览器会自动处理边界和对齐,不再需要手写复杂的逻辑。

类似的变化也发生在滚动动画上。过去如果想让动画跟随滚动进度变化,几乎离不开 GSAP ScrollTrigger。Web 开发者需要监听 scroll 事件、计算位置,再把结果映射到动画上。而现在,CSS 提供了滚动驱动动画(如 scroll-timeline),可以用声明式的方式直接把滚动和动画绑定起来,不仅代码更简单,而且性能更好,因为这些计算是在浏览器内部完成的。

在交互动效方面也是如此。像 Framer Motion 这样的库长期以来负责处理状态切换、进入和离开动画,甚至页面过渡。但随着 CSS 动画能力的增强(例如更强的 transiton@starting-style 以及视图过渡),这些原本依赖 JavaScript 控制时间轴的效果,正在逐渐转向用 CSS 描述。

再看弹窗和组件行为。过去我们常借助 Radix UI Headless UI 来处理模态框的焦点管理、键盘交互和可访问性细节。这些并不只是“显示一个弹窗”,而是涉及一整套复杂行为。而现在,浏览器提供了 <dialog>popover 等原生能力,很多行为已经内建,不再需要额外的库来兜底。

最后是表单控件这个长期的痛点。由于原生 <select> 难以样式化,开发者往往选择使用诸如 React Select 这样的库,从零实现一个组件。但随着 CSS 对原生控件样式能力的不断增强,我们越来越可以在保留原生语义和可访问性的前提下,对其进行自定义,而不是彻底重写。

把这些变化放在一起看,可以发现一个清晰的趋势,我们正在从“用 JavaScript 补足浏览器能力”,转向“直接使用浏览器提供的能力”。这不仅意味着更少的代码体积,也意味着更好的性能、更低的维护成本,以及更少需要手动处理的边界问题。本质上,这是一种回归——让 CSS 负责它本该负责的事情。

换句话说,一批被期待已久的 CSS 特性正在陆续落地,而且它们的目标很明确,替代那些过去必须依赖 JavaScript 才能实现的 UI 模式。而且,这不是“并不多能用”的替代方案。它们是浏览器提供的平台级能力——能够处理边界情况,运行在正确的渲染线程上,并且不依赖任何第三方库。

接下来,我们一起来看看,这些能力已经发展到了什么程度,它们具体替代了哪些 JavaScript 方案,你可以删掉多少代码,以及还有哪些问题,CSS 依然没有解决。

锚点定位

在很长的一段时间里,从提示框(Tooltip)、下拉菜单(DropdownMenu)、弹窗(Popover)到各种浮动菜单,这类 Web UI 一直依赖 JavaScript 来“贴住”触发元素。你要么使用第三方 JavaScript 库,要么自己写一套基于 getBoundingClientRect 的计算逻辑,在滚动、缩放和布局变化时不断更新位置。归根结底,浏览器本身一直缺少一个原生能力——让一个元素跟随另一个元素定位

CSS 锚点定位从根本上改变了这一点。现在,你可以用 anchor-name 声明一个锚点元素,然后通过position-anchor 将目标元素与锚点元素关联起来,再通过 anchor() 函数或者 position-areaposition-try ,将目标元素相对于锚点元素进行定位。所有的位置计算都交给浏览器完成,而且不仅仅是“放在那里”,连溢出处理也一并解决。比如,当 Tooltip 可能被视口裁切时,你可以定义多个备用位置,浏览器会按顺序自动尝试并选择合适的方案。

.button {
    anchor-name: --my-button;
}

.tooltip {
    position: absolute;
    position-anchor: --my-button;
    top: anchor(bottom);
    left: anchor(left);
}

Demo 地址:codepen.io/airen/full/…

不需要 JavaScript,不需要 ResizeObserver,也不需要监听 scroll。一旦建立了锚点关系,浏览器就会在滚动、尺寸变化以及布局变动时,自动保持浮层元素与锚点之间的对齐关系。这类原本需要频繁计算和同步的逻辑,现在完全交由浏览器底层处理。

这里展示的是基于 CSS 锚点定位实现的一个最基础版本的 提示框组件。但它的能力远不止于此。CSS 锚点定位不仅可以用来做提示框组件,还可以扩展到许多过去必须依赖 JavaScript 的交互场景,比如:熔岩导航菜单元素之间的联动动画、类似 macOS Dock 的导航效果滑动跟随的悬浮层带“磁性吸附”的悬浮交互,甚至是元素之间的连接线效果等。这些原本需要复杂计算和事件监听的功能,现在都可以用更原生、更简洁的方式实现。

在浏览器支持方面,Google Chrome 已在 2024 年(Chrome 125)提供了较为完整的实现,而 Mozilla Firefox 和 Safari 也在持续推进中。目前,一些基础能力(如 anchor-name anchor() 函数和 position-area)已经可以在主流浏览器中使用;而像 position-tryposition-visibility 这样的高级特性,仍在逐步落地。

温馨提示:如果你希望更系统、更深入地掌握 CSS 锚点定位相关特性,建议抽时间阅读《CSS 锚点定位: 探索下一代 Web 布局》和《CSS 布局:重聊 CSS 锚点定位》这两节课程。它们会从基础到进阶,帮助你全面理解这一能力,并应用到实际项目中。

Popover API

CSS 锚点定位和 HTML Popover API,其实是在解决同一个问题的两个不同侧面。CSS 锚点定位负责“浮层出现在哪里”,HTML Popover API 负责“浮层是否出现”。也就是说,CSS 锚点定位负责处理位置,而 HTML Popover API 处理可见性、交互行为以及可访问性。一套完整的提示框(Tooltip)或弹窗(Popover)组件,通常需要两者配合使用。

在 Popover API 出现之前,构建一个真正“可用”的 Popover 、下拉菜单或非模态弹框,往往需要使用 JavaScript 实现一整套复杂逻辑:焦点管理、维护 aria-expanded 状态、处理键盘交互、监听点击外部关闭等。这些细节不仅繁琐,而且非常容易出错,这也是为什么像 Headless UI、Radix UI 这类库长期存的原因。

而现在,事情变得简单得多。通过 HTML 的 popover 属性以及配套的 popovertarget ,你只需要少量的 HTML 结构,就可以获得一个原生、可访问的 Popover 组件。浏览器会自动帮你处理一系列关键行为,包括显示与隐藏的切换、点击外部关闭、按下 Escape 键关闭、使用 ::backdrop 创建背景遮罩、焦点管理,以及顶层渲染(popover 始终会显示在页面最上层,无需再处理复杂的 `z-index``)。

<button invoketarget="ref_1" popovertarget="ref_1">roll</button>
<div  popover id="ref_1" class="card"></div>

Demo 地址:codepen.io/airen/full/…

这些能力如今已经在 Google Chrome、Mozilla Firefox 和 Safari 中成为基础功能。对于绝大多数常见场景来说,它已经可以替代过去依赖各种第三方 JavaScript 库所实现的整套交互逻辑。

Popover API 非常适合用来构建提示框下拉菜单、上下文菜单、通知提示、引导提示等各种非模态浮层——也就是那些用户点击外部时应该自动关闭的界面。但需要注意的是,它并不是用来替代模态对话框的,对于需要阻止背景交互的场景,仍然应该使用 <dialog> 元素(稍后会详细介绍)。

温馨提示,如果你想更深入的了解 Popover API 相关的特性,请移步阅读《CSS 布局:CSS 与 Popover API 的结合》!

AIM(Anchor Interpolated Morph)

AIM (Anchor Interpolated Morph,锚点插值变形)是一种前沿的 Web 动画技术,它的核心理念是将元素视为“锚点”,通过位置、尺寸和形状的插值计算,实现元素之前的平滑过渡动画。通俗来说,AIM 能让一个元素看起来像是从另一个元素位置“生长”出来,或者在关闭时平滑地返回原来的位置。与传统的 过渡或动画 不同,AIM 不仅关注单一属性的变化(例如位置或透明度),而是综合考虑元素的空间信息和布局锚点,在不同状态之间自动生成连贯的动画路径,使界面变化更加自然流畅,同时保留用户的空间感知,让用户清楚地理解元素之间的关系。从动画效果来看,AIM 与早期的 F.L.I.P(First, Last, Invert 和 Play)技术非常相似。

只不过,在技术实现上,AIM 利用了一系列 现代 CSS 特性CSS 锚点定位anchor()anchor-size())允许一个元素将另一个元素的坐标和尺寸作为起点,从而建立空间关联。 @starting-style 规则定义元素在渲染瞬间的样式,例如弹出框初次出现的状态,使入场动画自然流畅。interpolate-size 属性则支持在内联关键词尺寸(如 automin-content)与具体长度尺寸(如 300px)之间动画化,保证元素在状态变化时平滑过渡。这些功能结合在一起,让浏览器可以在渲染时自动计算元素的起始状态和目标状态,并在它们之间生成连续动画,无需 JavaScript 干预,同时动画可以自然中断,响应用户操作。

<div class="cell">
    <button popovertarget="img-1" aria-label="Open Image of Mountain Landscape">
        <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=700&h=400&fit=crop&auto=format" alt="Mountain landscape" loading="lazy" />
    </button>
    <img popover id="img-1" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1800&auto=format" alt="Mountain landscape" loading="lazy" />
</div>
:root {
    interpolate-size: allow-keywords;
}

button {
    anchor-name: --morph;
}

[popover] {
    --speed: 0.5s;
    position-anchor: --morph;
    
    @media (prefers-reduced-motion: no-preference) {
      transition:
          display var(--speed) allow-discrete,
          overlay var(--speed) allow-discrete,
          height var(--speed) ease,
          width var(--speed) ease,
          top var(--speed) ease,
          left var(--speed) ease;
    }
    
    &:popover-open {
        height: auto; /* 或 fit-content */
        max-height: 70dvb;
        width: 70dvi;
        left: 15dvi;
        top: 15dvb;
        
        @starting-style {
            left: anchor(left);
            top: anchor(top);
            width: anchor-size(width);
            height: anchor-size(height);
        }
    }
    &:not(:popover-open) {
        left: anchor(left);
        top: anchor(top);
        width: anchor-size(width);
        height: anchor-size(height);
    }
}

AIM 技术在实际应用中非常适合图片画廊、列表展开和弹窗动画等场景。例如,在图片画廊中,缩略图可以作为锚点,大图从缩略图位置动画展开,填充屏幕;关闭时,大图又平滑缩回原位。这种动画方式不仅直观、有趣,还增强了用户对界面元素来源和去向的认知,使交互更易理解。

Demo 地址:codepen.io/airen/full/…

温馨提示,如果你对 AIM 技术感兴趣的话,建议你花点时间阅读《Web 动效:锚点定位之锚点插值变形(AIM)解析》和《Web 动效:用 AIM 做出高级感 UI 过渡》!

dialog 元素

如果说 Popover API 适用于非阻塞的浮层——用户点击外部即可关闭,那么 HTML 的 <dialog> 则用于真正的模态体验(模态框):在关闭之前,必须完全占据用户注意力的那类交互。

在传统的 JavaScript 实现中,这类模态对话框往往需要一整套繁琐的逻辑,比如创建背景遮罩(通常用 div 来创建)、模态框的定位、手动设置 aria-modal ,在模态框内捕获焦点、关闭时恢复焦点,以及禁止页面滚动等。这些细节不仅复杂,而且很容易遗漏,进而影响可访问性。

而原生 <dialog> 元素把这一切都内置了。结合 ::backdrop 伪元素和 .showModal() 方法,你只需要一个 HTML 元素和少量的 JavaScript 就可以创建一个完整、可访问的模态对话框。浏览器会自动处理焦点管理、遮罩层以及交互行为,大大简化了实现过程。

<button class="button" onclick="document.getElementById('my-dialog').showModal()">Open dialog</button>

<dialog id="my-dialog">
    <div class="dialog-header">
        <div class="dialog-title">Delete this file?</div>
    </div>
    <div class="dialog-body">
        This action cannot be undone. The file will be permanently removed from your workspace.
    </div>
    <div class="dialog-footer">
        <button class="button secondary" onclick="document.getElementById('my-dialog').close()">Cancel</button>
        <button class="button" onclick="document.getElementById('my-dialog').close()">Delete</button>
    </div>
</dialog>

其中最关键的“模态陷阱”——也就是 Tab 键只在对话框内部循环、按下 Escape 键自动关闭——同样由浏览器原生处理。从 2022 年开始,这些能力已经成为主流浏览器的基础支持,让模态对话框的实现从“复杂工程”变成了“开箱即用”。

Demo 地址:codepen.io/airen/full/…

温馨提示,有关于 dialog 更详细的介绍,请移步阅读《用于美化模态框的 :modal::backdrop》!

Popover API vs. dialog 元素:有什么区别?

在 Popover API 和 dialog 元素看起来都在处理“弹出层”,但本质上并不是同一类问题。两者在可访问性和交互模式上存在明显差异,这也是它们各自适用场景不同的根本原因。Popover API 更偏轻量、非阻塞的浮层,而 dialog 则是为严格的模态交互而设计。

或者说,两者核心区别在于页面的其他部分是否仍然可以交互:

功能 Popover API dialog 元素
阻止背景交互
点击外部关闭
焦点陷阱
滚动锁定
适用场景 Tooltip、下拉菜单、Toast 等 确认框、表单、警告
API 使用方式 纯声明式 HTML 需要 JavaScript(.showModal()

两者的控制模型也体现了这一差异。Popover API 完全不需要 JavaScript —— popovertarget 就能在 HTML 中把按钮和面板绑定起来。dialog 如果只用 .show() 打开,则是非模态框,几乎没有实际用处;真正的模态框(具有模态行为)依赖 .showModal()。两都都可以用 Escape 关闭,但只有 dialog 会捕获焦点并在关闭前阻止与页面其他部分的交互。

因此,在实际使用中可以遵循一个简单的原则:大多数普通的弹出层场景优先选择 Popover API;只有当你需要一个真正阻止用户与页面其他部分交互的模态对话框时,才使用 dialog

detailssummary

原生 <details><summary> 提供了一种极其直接的方式来实现展开与折叠的交互模式。像 FAQ、手风琴、可折叠信息区域这类 UI,在过去往往需要通过 JavaScript 控制状态切换、管理类名甚至处理动画,而现在,我们可以用一个段箭洁的 HTML 原生完成。

<details>
    <summary>查看详细内容</summary>
    <p>这里是展开后的内容,用户可以点击 summary 来切换显示。</p>
</details>

更重要的是,这不仅仅是“更简单”的实现方式,它还是一种语义化且可访问的解决方案。浏览器会自动处理展开状态(open 属性)、键盘交互(如 Enter / Space 切换)、以及与屏幕阅读器的兼容性。这意味着你不再需要手动管理 aria-expanded、焦点状态或键盘事件,这些过去容易出错的细节,现在都由浏览器原生保证。

从扩展性来看,<details> 也并不局限于基础用法。你可以通过 CSS 精细控制它的外观,比如自定义 summary 的样式、隐藏默认箭头、甚至结合动画实现更流畅的展开效果。对于手风琴场景,还可以通过少量 CSS(或配合 :has() 等现代选择器)实现“同一时间只展开一个”的交互,而无需 JavaScript。

Demo 地址:codepen.io/airen/full/…

在浏览器支持方面,<details><summary> 已经成为现代浏览器的基础能力,可以放心用于生产环境。而在开发体验上,它几乎是“零成本”的:无需引入库、无需编写逻辑,就能获得完整的交互与可访问性支持。

对于绝大多数折叠类 UI 场景,这通常是最简单、最可靠、也最推荐的实现方式

温馨提示,有关于这方面更详细的介绍,请移步阅读《CSS 布局:创建手风琴组件》和《Web UI:使用现代 CSS 创建手风琴组件》!

需要特别指出的是,发展到今天,很多“内容切换”类的交互,其实已经不再依赖 JavaScript。借助现代 CSS 与原生 HTML 能力,你完全可以用纯 CSS(或极少量声明式 HTML) 实现这些效果。常见的方式包括:利用 :has() 搭配 inputcheckbox / radio)实现状态驱动的切换,使用 <dialog> 构建模态交互,通过 Popover API 控制浮层显示,以及 <details><summary> 实现折叠内容等。

这些方案不仅实现更简单,而且更加语义化、可维护,并且在可访问性方面由浏览器原生保障。如果你对这一类“用 CSS 替代 JavaScript”的实现方式感兴趣,可以进一步阅读《CSS 布局:切换内容的现代方式》深入了解。

滚动驱动动画

在过去,所谓“滚动驱动动画”,几乎等同于一套固定模式。用 JavaScript 监听 scroll 事件,配合 requestAnimationFrame ,在每一帧中读取 window.scrollY ,再去更新 CSS 自定义属性或 transformopacity 等属性值。随着页面复杂度的提升,这种做法很容易带来性能问题——频繁的主线程计算、布局抖动,以及难以维护的同步逻辑。像 GSAP ScrollTrigger 这样的库,本质上就是为了让这种模式更可控、更易管理而存在。

而现在,CSS 正在接管这一切。随着滚动驱动动画规范的引入,你可以通过 animation-timelinescroll()view())直接将动画绑定到滚动进度上。也就是说,动画不再依赖 JavaScript 去驱动,而是由浏览器根据滚动状态自动计算和执行——整个过程完全声明式,无需额外脚本。

@keyframes spin {
    to {
        rotate: y 5turn;
    }
}

@supports (animation-timeline: scroll()) {
    @media (prefers-reduced-motion: no-preference) {
        figure {
            animation: spin linear both;
            animation-timeline: scroll();
        }
    }
}

Demo 地址:codepen.io/airen/full/…

这种变化的关键在于执行方式的不同,动画运行在浏览器的合成线程上,而不是主线程。这意味着即使在复杂页面或高负载情况下,动画依然可以保持流畅,不会因为 JavaScript 阻塞而掉帧。从“手动驱动”到“浏览器原生驱动”,这正是 CSS 在这一类交互能力上的一次质变。

更重要的是,CSS 滚动驱动动画的意义,早已不只是“做滚动动画”这么简单。它本质上提供了一种基于滚动状态驱动 UI 的能力,而这正是过去大量依赖 JavaScript 才能实现的交互核心。

借助这些能力,你可以用纯 CSS 实现一整类复杂效果。例如:根据滚动位置判断内容状态(如文本是否溢出数量尺寸变化)、构建滚动进度指示器自动高亮当前章节的目录(TOC)、实现滚动遮罩效果,甚至是更具表现力的交互——比如类似 macOS Dock 的图标放大效果按钮在滚动中切换为导航栏元素的渐入渐出动画带渐变效果的滚动条滑动删除3D 翻转滚动视差,以及轮播CoverFlow 等组件。

这些过去需要大量事件监听、状态同步和手动计算的逻辑,现在可以直接通过 CSS 声明式地完成。你不再需要“监听滚动再驱动 UI”,而是把“滚动本身”作为动画时间轴,让浏览器去完成剩下的一切。这种思维方式的转变,才是它真正强大的地方。

温馨提示,如果你想进一步玩转 CSS 滚动驱动动画?不妨看看《CSS 滚动驱动动效》和《Web 动效:滚动驱动动效之实战技巧》课程!

滚动状态查询

CSS 滚动状态查询可以看作是对容器查询的一次重要扩展(容器查询在滚动状态查询之前已有**尺寸容器查询样式容器查询**)。它将由浏览器内部管理的滚动状态直接暴露给 CSS,使得 Web 开发者可以基于滚动行为进行样式控制,而完全不需要借助 JavaScript。该特性已在 Google Chrome 133 中发布,Mozilla Firefox 和 Safari 的支持也正在推进中。

这一功能的核心在于,它提供了三类常见但过去必须依赖 JavaScript 才能获取的滚动状态:stuck (粘性状态)、 snapped (吸附状态)和 scrollable (可滚动状态)

首先是 stuck。对于 position: sticky 元素,过去如果想知道它是否已经“吸附”在顶部,通常需要借助一个额外的哨兵元素,再配合 IntersectionObserver 来判断。而现在,只需声明 container-type: scroll-state,就可以在 CSS 中直接根据是否处于粘性状态来应用样式,比如在吸顶时添加阴影效果。这种从“监听状态”到“声明状态”的转变,大大简化了实现方式。

.stuck-top {
    /* 粘性定位,是必须要的 */
    position: sticky;
    top: 0;
    
    @supports (container-type: scroll-state) {
        container-type: scroll-state; /* 定义滚动状态查询容器 */
        
        .heading {
            transition: box-shadow 0.5s ease-out;
            
            @container scroll-state(stuck: top) {
                box-shadow: 
                    rgb(0 0 0 / 0.6) 0px 12px 28px 0px,
                    rgb(0 0 0 / 0.1) 0px 2px 4px 0px,
                    rgb(255 255 255 / 0.05) 0px 0px 0px 1px inset;
            }
        }
    }
}

Demo 地址:codepen.io/airen/full/…

其次是 snapped。在滚动捕捉场景中,过去要判断哪个元素当前被吸附,需要监听 scrollsnapchange 事件,并手动切换类名。而现在,被吸附的元素可以直接通过 CSS 感知自身状态,从而改变样式或影响其子元素。例如:

.list {
    scroll-snap-type: x mandatory;
    overflow-x: auto;
    scroll-padding-inline: 2ch;

    li {
        scroll-snap-align: center;

        @supports (container-type: scroll-state) {
            container-type: scroll-state;
    
            :is(.blur, .content) {
                translate: 0 100%;
                transition: translate 0.2s ease-in-out;
                
                @container scroll-state(snapped: x) {
                    translate: 0;
                }
            }
    
            figure img {
                @container  scroll-state(snapped: x) {
                    mix-blend-mode: darken;
                    scale: 1.5;
                }
                
                @container not  scroll-state(snapped: x){
                    filter: grayscale(1);
                }
            }
        }
    }
}

Demo 地址:codepen.io/airen/full/…

最后是 scrollable。像“只有在内容可滚动时才显示滚动阴影”或“滚动到一定位置才出现返回顶部按钮”这类常见需求,以往通常依赖滚动监听或观察器 API。现在,这些都可以通过 CSS 条件直接表达——当某个方向不可滚动时隐藏对应 UI,从而实现更简洁、声明式的控制。

.scroll-container {
    overflow-y: auto;
    overflow-x: hidden;
    scroll-snap-type: y mandatory;
    inline-size: 30em;
    block-size: 18.735lh;
    border: 1px solid var(--surface-1);
    scroll-padding-block: 10px;
    overscroll-behavior: contain;
    display: grid;

    > * {
      grid-area: 1/1;
    }

    > .scroll-indicator {
        place-self: end;
        position: sticky;
        inset-block-end: 10px;
        inline-size: 100%;
        text-align: center;
        transition: translate 0.2s ease;

        > svg {
            background: var(--surface-2);
            aspect-ratio: 1;
            border-radius: 1e3px;
            inline-size: 48px;
            block-size: 48px;
        }
    }

    @supports (container-type: scroll-state) {
        container-type: scroll-state size;

        > .scroll-indicator {
            @container scroll-state((scrollable: top) or (not (scrollable: bottom))) {
                translate: 0 calc(100% + 10px);
            }
    
            @container scroll-state((scrollable: top) and (not (scrollable: bottom))) {
                translate: 0 calc(100% + 10px);
                rotate: 0.5turn;
            }
        }
    }

    .item {
        scroll-snap-align: start;
        scroll-snap-stop: always;
    }
}

Demo 地址:codepen.io/airen/full/…

这三类状态查询,本质上替代了过去大量基于事件监听(scroll)或观察器(IntersectionObserver)的实现方式。你不再需要“监听滚动再更新状态”,而是可以直接在 CSS 中描述“当处于某种滚动状态时应该如何表现”,让浏览器去完成剩余的工作。这正是现代 CSS 在交互能力上的又一次重要跃迁。

温馨提示:如果你希望更深入理解滚动状态查询的原理与实际应用,建议进一步阅读《Web UI:容器查询之滚动状态容器查询》和《Web UI:CSS 中的滚动驱动方向》这两篇内容。它们会从基础概念到进阶实践,帮助你全面掌握这一能力,并将其灵活运用到真实项目中。

更有趣的是,如今结合 CSS 的 滚动驱动动画滚动捕捉等能力,你已经可以用纯 CSS 构建复杂的交互组件。像 Web 上常见的轮播Carousel)甚至 CoverFlow 这类具有空间感和动态效果的组件,过去往往需要一整套 JavaScript 逻辑来驱动,而现在则可以通过声明式的方式直接实现。例如,通过滚动捕捉控制滚动对齐,再配合滚动驱动动画绑定滚动进度,你可以轻松实现类似 Nintendo Switch 主屏幕那种带有缩放、聚焦和过渡效果的轮播体验

Demo 地址:codepen.io/airen/full/…

需要特别提出的是,CSS 正在引入一种新的查询能力——锚定容器查询。它是在尺寸容器查询、样式容器查询以及滚动状态查询之后的又一次扩展,进一步增强了 CSS 对“上下文感知”的能力。

当你结合 CSS 锚点定位使用时,这种能力会变得尤为强大。通过锚定容器查询,你可以让组件根据相对于锚点的位置变化自动调整自身表现,例如在发生位置回退时切换样式,而这一切都可以在 CSS 中声明完成。

Demo 地址:codepen.io/airen/full/…

换句话说,过去需要通过 JavaScript 检测位置、监听变化并手动更新 UI 的逻辑,现在可以完全交由 CSS 处理。这使得复杂的浮层、提示框以及自适应交互界面,能够以更简洁、更稳定的方式实现,真正做到“由 CSS 驱动 UI 行为”。有关于这方面更详细的介绍,请移步阅读《Web 组件:一种基于锚定容器查询的动态切换提示方案》!

视图过渡

在过去,单页应用(SPA)的页面切换动画——也就是路由切换时“旧页面淡出,新页面淡入”的效果,几乎完全依赖 JavaScript。无论是 React 的 react-transition-group,还是 Vue 的 <Transition> 组件,本质上都在帮你管理一套流程:克隆元素、设置绝对定位、同步多个动画状态,并手动控制 opacitytransform 的时间轴。这不仅实现成本高,而且维护起来也相当繁琐。

而 CSS 视图过渡(CSS View Transition API)将这一切交还给浏览器来完成。你只需要用 document.startViewTransition() 包裹一次 DOM 状态更新,浏览器就会自动捕捉更新前后的界面状态,并在两者之间执行平滑过渡(例如交叉淡入淡出)。整个过程无需手动管理中间状态,同时你仍然可以通过 CSS 自定义动画效果。

对于同一文档内的状态切换,这几乎就是完整的 API 使用方式。而在跨文档导航(也就是真正的页面跳转)中,只需一行 CSS —— @view-transition {navigation: auto} ——即可启用类似的过渡效果,让多页面应用也能拥有接近 SPA 的流畅体验。

更进一步,命名视图过渡还允许你针对特定元素创建跨页面动画,比如卡片从列表页“展开”进入详情页。这类效果在过去通常需要复杂的布局克隆与同步技巧,而现在可以用更直观的方式实现。

在浏览器支持方面,Google Chrome 已率先支持该特性,而 Safari 和 Mozilla Firefox 也在持续推进中。整体来看,这标志着页面过渡动画正从“框架能力”逐步转变为“平台能力”。

Demo 地址:codepen.io/airen/full/…

时至今日,CSS 视图过渡(View Transitions)已逐渐成为一种通用的界面状态过渡机制。借助它,你可以轻松实现诸如主题切换(如浅色/深色模式的平滑过渡)、展开与折叠的动效、甚至是带有氛围感的动态灯光效果等。这些原本需要精细控制时间轴和状态同步的交互,现在都可以交由浏览器统一处理。

这种能力的关键在于,它将“状态变化”与“视觉过渡”解耦:你只需要关心界面更新本身,而动画如何发生、如何衔接,则由浏览器自动完成,同时又允许你通过 CSS 精细定制细节。这让复杂动效的实现变得更加自然,也更易于维护。

如果你想进一步探索这些技巧和实际应用场景,可以移步阅读《解锁 CSS View Transitions API 的魔力》,深入了解这一能力的更多可能性。

自定义下拉选择框

自定义下拉选择框,几乎是 Web 上被“重复造轮子”最多的 Web UI 组件之一。长期以来,由于原生 <select> 难以进行深度样式化,各大设计系统不得不从零实现一整套替代方案:隐藏原生 <select> 以保留可访问性,用自定义元素作为触发器,手动定位下拉列表,实现键盘导航、焦点管理,以及为屏幕阅读器补充语义支持。为了替代一个基础的表单控件,往往需要成百上千行代码,这本身就是平台能力缺失的体现。

而“可定制下拉选择框”正是为了解决这一长期痛点。它允许 Web 开发者通过 CSS 直接控制 <select> 以及其内部结构。不令可以自定义触发按钮的外观,还可以样式化下拉容器,甚至精细控制每一个 <option> 。更进一步,你还可以在选项中使用任意 HTML 内容,让下拉选择框具备更丰富的表达能力。

@supports (appearance: base-select) {
    select {
        anchor-name: --select;
        padding-block: 0;
        
        &::picker-icon {
            display: none;
        }
      
        .icon {
            width: calc(var(--option-size) * 0.375);
            height: calc(var(--option-size) * 0.625);
        }
    }
    
    /* 美化下拉框 */
    ::picker(select) {
        --counts: 6;
        --rotation-divide: calc(360deg / var(--counts));
        position-anchor: --select;
        top: anchor(center);
        left: anchor(center);
        translate: -50% -50%;
        overflow: visible;
        transition: overlay 0.5s, display 0.5s;
        transition-behavior: allow-discrete;
        margin: 0;
        padding: 0;
        background: transparent;
        border: none;
    }
}

这项能力的意义在于,它将一个“必须用 JavaScript 重写”的组件,重新带回到原生平台层面。你不再需要在“可访问性”和“设计一致性”之间做取舍,而是可以同时获得两者。

目前,Google Chrome 已在实验性标志下提供了初步实现。虽然规范仍在持续演进中,但整体方向已经非常清晰:未来,自定义下拉选择框将不再是复杂的工程问题,而只是一次普通的 CSS 样式设计。

Demo 地址:codepen.io/airen/full/…

温馨提示:如果你想更深入了解自定义下拉选择框的实现原理与进阶用法,可以进一步阅读相关内容,深入探索这一特性的更多可能性与实践技巧。

焦点组

在组合型控件(如工具栏、标签列表、单选组或菜单)中实现键盘的箭头导航,一直是一项重复且繁琐的工作。传统做法通常需要编写大量 JavaScript,比如监听 keydown 事件、判断 ArrowRightArrowLeftArrowUpArrowDown,手动维护 tabindex,并在用户通过 Tab 重新进入组件时,记住上一次的焦点位置。几乎每一个 UI 库都有自己的一套实现方案,比如 React 中常见的 roving-tabindex 模式,或 Fluent UI 提供的类似工具(FocusZone),本质上都在解决同一个问题。

而 Open UI 提出的 focusgroup 属性,试图将这一整套逻辑声明式地交还给浏览器。只需要在容器元素上添加一个属性,浏览器就可以自动管理其内部可聚焦子元素之间的箭头键导航,无需任何 JavaScript 参与。这不仅简化了实现,也让行为更加一致和可预测。

<div role="toolbar" focusgroup aria-label="Text Formatting">
    <button type="button" tabindex="-1">Bold</button>
    <button type="button" tabindex="-1">Italic</button>
    <button type="button" tabindex="-1">Underline</button>
</div>

同时,focusgroup 还提供了一些可选配置,用来微调导航行为。例如,可以通过 inlineblock 限制导航方向(仅水平或垂直),使用 wrap 实现循环导航,以及通过 no-memory 控制每次重新聚焦时是否回到初始项,而不是上一次的焦点位置。

<div role="tablist" focusgroup="inline wrap no-memory">
    <button role="tab" tabindex="0" aria-selected="true">Mac</button>
    <button role="tab" tabindex="-1" aria-selected="false">Windows</button>
    <button role="tab" tabindex="-1" aria-selected="false">Linux</button>
</div>

更进一步,这一机制还支持嵌套结构。例如,一个水平的菜单栏内部包含垂直的子菜单,每个层级都可以拥有独立的导航轴向,从而让不同方向的箭头键各司其职,形成更自然的交互体验。

目前,focusgroup 仍处于提案阶段,尚未被浏览器正式实现。不过,其更具体的演进版本正在积极推进中。考虑到它所替代的模式在 Web 开发中极为普遍,这项能力很可能会成为未来原生平台的一部分。

瀑布流布局

像 Pinterest 这种瀑布流布局(高度不一的卡片按列紧密排列、自动填补空隙),一直是 Web 上的经典布局需求。但长期以来,这类布局几乎只能依赖 JavaScript 实现。像 Masonry.js 、Isotope 这样的库之所以流行,正是因为传统的 CSS Grid无法原生支持这种“跨行填充”的布局方式——Grid 项目始终遵循严格的行列轨道,无法自动回填空白。

而 CSS 正在补上这一块能力。最初的 Masonry 提案通过 grid-template-row: masonry (以及对应的列方向变体),让浏览器自动计算并填补布局中的空隙,从而实现真正意义上的瀑布流布局,无需额外的脚本介入。

.masonry {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: masonry;
    gap: 16px;
}

不过,规范的发展并没有止步于此。随着讨论的推进,这一能力被重新设计并更名为 Grid Lanes,强调其作为 CSS Grid 体系的一部分,而不是一个全新的 display 类型。新的方向是通过类似 grid-lanes 机制,让布局在既有 Grid 模型中具备“跨轨道填充”的能力,从而实现更加灵活的排布方式。

.masonry {
    display: grid-lanes;
    grid-template-columns: repeat(3, 1fr);
    gap: 16px;
}

这一转变的意义在于,它不只是“让瀑布流变得可用”,而是将这一能力系统性地整合到 CSS 网格布局体系。Web 开发者不再需要依赖 JavaScript在布局完成后再进行二次计算(这也是导致布局抖动和性能问题的根源),而是可以直接交由浏览器在布局阶段完成。

目前,Mozilla Firefox 已经在实验性标志下提供相关实现,Google Chrome 也在积极推进中。虽然规范仍在演进,但方向已经非常明确:未来,瀑布流布局将成为 CSS 的原生能力,而不是一个需要额外库来“修补”的特例。

Demo 地址:codepen.io/airen/full/…

温馨提示:如果你想更深入了解瀑布流布局相关的实现原理与实践方式,可以进一步阅读《CSS Grid 之瀑布流布局:masonrymasonry-auto-flow》和《CSS 布局:从 Masonry Layout 到 Grid Lanes——CSS Grid 的新能力》获取更详细的讲解。同时,如果你希望系统性掌握现代 Web 布局(包括 Grid、Flexbox 以及最新布局能力)的设计思路与实战技巧,也可以参考我的小册《现代 Web 布局

field-sizing

field-sizing: content 为表单元素带来了一项看似简单却非常实用的能力:让 <input><select><textarea> 可以根据内容自动调整尺寸。也就是说,输入多少内容,字段就增长多少空间,无需额外干预。

在过去,如果你想实现一个“自动增高”的 textarea ,通常需要借助 JavaScript 监听输入事件、读取 scrollHeight 、再手动更新高度。这不仅代码繁琐,还容易引发性能生布局问题。而现在,这一切都可以通过一行 CSS 完成:

textarea {
  field-sizing: content;
}

Demo 地址:codepen.io/airen/full/…

这种变化的意义在于,它将一个常见但细碎的交互需求,从“脚本逻辑”转变为“样式声明”。你不再需要关心何时更新尺寸,也不需要处理边界情况,浏览器会根据内容自动完成计算与渲染。

目前,Google Chrome 已在 2024 年支持该特性。虽然它只是一个相对小的功能点,但在实际开发中却非常“解放生产力”——一旦用上,就很难再回到过去的实现方式。

温馨提示:如果你想更深入了解 field-sizing 的工作原理、兼容性以及更多实际应用场景,可以移步阅读《CSS 排版:利用 field-sizing 属性实现自动尺寸调整》,进一步探索这一特性的细节与最佳实践。

sibling-index()sibling-count()

在过去,如果你想根据元素在 DOM 中的位置来做样式变化,比如实现“第几个元素有不同的样式”、“根据数量做渐变效果”或“按顺序延迟动画”,通常需要借助 JavaScript 来计算索引或统计数量,再动态添加类名或设置变量。

sibling-index()sibling-count() 的出现,让这些需求可以直接在 CSS 中完成。它们允许你获取元素在同级中的位置索引以及同级元素总数,从而实现基于结构的动态样式控制,无需任何 JavaScript 参与。

例如,sibling-index() 可以用来为列表创建基于顺序的动画延迟,而 sibling-count() 则可以帮助你根据元素数量计算布局或分布效果。两者结合,可以构建出更加灵活的 UI 表现,比如均匀分布、渐变强度、然度计算等。

.wave-bar {
    animation: wave 1.2s ease-in-out infinite;
    animation-delay: calc(sibling-index() * 0.1s);
}

Demo 地址:codepen.io/airen/full/…

这种能力的关键在于,它把“结构信息”直接暴露给 CSS,让样式可以感知上下文,而不再只是静态描述。这意味着很多过去需要“读取 DOM → 计算 → 写回样式”的流程,现在可以一步到位地用 CSS 声明完成。

虽然这些特性目前仍处于提案或实验阶段,但它们所代表的方向非常明确:CSS 正在逐步具备处理“逻辑关系”的能力,让越来越多原本属于 JavaScript 的工作,回归到样式层来解决。

温馨提示:如果你想更深入了解 sibling-index()sibling-count() 的原理与实际应用,可以继续阅读下面这几节课程,系统掌握它们在复杂 UI 场景中的使用方式与技巧。

原生 CSS 条件判断

过去,CSS 自定义属性虽然可以通过“回退值”实现一些类似条件判断的效果,但本质上只是权宜之计,并不是真正的逻辑控制。你只能在有限的范围内“模拟条件”,而无法直接达到清晰的分支判断。

.switch {
    --i: 0; /* 开关关闭,复选框未选中 */
    
    label::after {
        translate: calc(var(--i) * 100%) 0; /* 白色滑块位于左侧*/
        transition: translate 300ms;
    }
    
    &:has(:checked) {
        --i: 1; /* 开关打开,复选框选中 */
        
        label::after {
            transition: translate 300ms linear;
        }
    }
}

Demo 地址:codepen.io/airen/full/…

而 CSS 的 if() 函数则为 CSS 带来了真正的条件表达能力。你可以在属性值中直接写出类似“如果…否则…”的逻辑,根据不同状态返回不同的样式值。

例如,假设一个设计系统支持三种主题:ocean(海洋)、forest(森林)以及默认主题。过去你可能需要为不同主题维护多套样式文件,或者通过 JavaScript 动态切换类名、管理一堆变量映射。现在,借助 if(),你可以在 CSS 中直接通过条件表达式按需定义主题样式,让逻辑更加直观且集中。例如,根据当前主题变量选择不同的颜色,而无需额外的结构或脚本参与。这种方式不仅减少了样式分散的问题,也让主题系统更易于维护和扩展。

.controls:has(#shamrock:checked) ~ .card {
    --theme: shamrock;
}
.controls:has(#saffron:checked) ~ .card{
    --theme: saffron;
}
.controls:has(#amethyst:checked) ~ .card{
    --theme: amethyst;
}

.card {
    /* 基础颜色 */
    --saffron: hsl(43 74% 64%);
    --shamrock: hsl(146 50% 40%);
    --amethyst: hsl(282 47% 56%);

    /* 文本颜色 */
    --saffron-text: light-dark(
        hsl(from var(--saffron) h s 3%),
        hsl(from var(--saffron) h s 92%)
    );

    --shamrock-text: light-dark(
        hsl(from var(--shamrock) h s 3%),
        hsl(from var(--shamrock) h s 92%)
    );

    --amethyst-text: light-dark(
        hsl(from var(--amethyst) h s 3%),
        hsl(from var(--amethyst) h s 92%)
    );

    /* 背景颜色 */
    --saffron-bg: light-dark(
        var(--saffron), 
        hsl(from var(--saffron) h s 20%)
    );

    --shamrock-bg: light-dark(
        var(--shamrock),
        hsl(from var(--shamrock) h s 18%)
    );

    --amethyst-bg: light-dark(
        var(--amethyst),
        hsl(from var(--amethyst) h s 22%)
    );

    /* 边框颜色 */
    --saffron-border: light-dark(
        hsl(from var(--saffron) calc(h - 90) s 40%),
        hsl(from var(--saffron) calc(h - 90) s 65%)
    );

    --shamrock-border: light-dark(
        hsl(from var(--shamrock) calc(h - 90) s 35%),
        hsl(from var(--shamrock) calc(h - 90) s 60%)
    );

    --amethyst-border: light-dark(
      hsl(from var(--amethyst) calc(h - 90) s 38%),
      hsl(from var(--amethyst) calc(h - 90) s 62%)
    );

    /* 主题切换*/
    /* 背景 */
    --background: if(
        style(--theme: saffron): var(--saffron-bg); 
        style(--theme: shamrock):  var(--shamrock-bg); 
        style(--theme: amethyst): var(--amethyst-bg);
    );

    /* 文本 */
    --color: if(
        style(--theme: saffron): var(--saffron-text); 
        style(--theme: shamrock): var(--shamrock-text); 
        style(--theme: amethyst): var(--amethyst-text) ;
    );

    /* 边框 */
    --border-color: if(
        style(--theme: saffron): var(--saffron-border); 
        style(--theme: shamrock): var(--shamrock-border); 
        style(--theme: amethyst): var(--amethyst-border) ;
    );

    background: var(--background);
    color: var(--color);
    border-color: var(--border-color);
}

Demo 地址:codepen.io/airen/full/…

实际上,上面的主题示例,即使不使用 if(),也可以通过 @container style() 查询来实现。你可以根据容器中定义的样式变量(例如主题标识),在不同条件下应用对应的样式规则。这种方式同样能够实现按需切换主题效果,并且完全基于 CSS 的声明式能力完成。

/* Card 的容器 */
main {
    container-type: inline-size;
    --theme: saffron;
}

.card {
    /* 基础颜色 */
    --saffron: hsl(43 74% 64%);
    --shamrock: hsl(146 50% 40%);
    --amethyst: hsl(282 47% 56%);

    /* --theme: saffron */
    @container style(--theme: saffron) {
        --color: light-dark(
            hsl(from var(--saffron) h s 3%),
            hsl(from var(--saffron) h s 92%)
        );
        --background: light-dark(
            var(--saffron),
            hsl(from var(--saffron) h s 20%)
        );
        --border-color: light-dark(
            hsl(from var(--saffron) calc(h - 90) s 40%),
            hsl(from var(--saffron) calc(h - 90) s 65%)
        );
    }

    /* --theme: shamrok */
    @container style(--theme: shamrock) {
        --color: light-dark(
            hsl(from var(--shamrock) h s 3%),
            hsl(from var(--shamrock) h s 92%)
        );
        --background: light-dark(
            var(--shamrock),
            hsl(from var(--shamrock) h s 18%)
        );
        --border-color: light-dark(
            hsl(from var(--shamrock) calc(h - 90) s 35%),
            hsl(from var(--shamrock) calc(h - 90) s 60%)
        );
    }

    /* --theme: amethyst */
    @container style(--theme: amethyst) {
        --color: light-dark(
            hsl(from var(--amethyst) h s 3%),
            hsl(from var(--amethyst) h s 92%)
        );
        --background: light-dark(
            var(--amethyst),
            hsl(from var(--amethyst) h s 22%)
        );
        --border-color: light-dark(
            hsl(from var(--amethyst) calc(h - 90) s 38%),
            hsl(from var(--amethyst) calc(h - 90) s 62%)
        );
    }

    background: var(--background);
    color: var(--color);
    border-color: var(--border-color);
}

/* 切换容器 main 的 --theme 的值 */
main:has(#shamrock:checked) {
    --theme: shamrock;
}
main:has(#saffron:checked) {
    --theme: saffron;
}
main:has(#amethyst:checked) {
    --theme: amethyst;
}

Demo 地址:codepen.io/airen/full/…

这也说明一个趋势,CSS 正在逐步提供多种路径来表达“条件逻辑”。无论是 if() 这样的内联条件函数,还是 @container style() 这样的上下文查询机制,本质上都在让组件具备更强的自适应能力,从而减少对 JavaScript 的依赖。

也就是说,样式不再只是对结果的描述,而是可以根据上下文、状态和条件主动做出响应。这种能力让很多过去需要“状态判断 + DOM 操作”的场景,可以直接在 CSS 层完成。

目前,这一特性仍处于实验阶段,但它所代表的方向非常清晰——CSS 正在从“声明样式”逐步演进为“具备一定逻辑能力的系统”,进一步压缩 JavaScript 在 UI 层的职责范围。

温馨提示:如果你想更深入了解 CSS if() 函数的语法细节、使用场景以及与其他特性的组合方式,可以移步阅读《Web UI:CSS if 语句与条件逻辑》和《CSS 技巧:如何在 CSS 中正确使用 if()》,进一步探索它在实际项目中的应用潜力。

CSS @function

一直以来,如果你希望在 CSS 中封装一段可复用的“计算逻辑”,通常只能依赖 CSS 处理器(比如 Sass 的函数),或者借助 JavaScript 在运行时动态计算。原生 CSS 虽然提供了 calc()min()max()clamp() 等函数,但它们更偏向表达式计算,而不是可复用的逻辑单元。

现如今,CSS 的 @function 为 CSS 引入了更接近“原生函数”的能力。你可以在 CSS 中定义一个带参数的函数,并在不同的地方调用它,从而根据输入动态返回计算结果。这意味着,一些重复计算逻辑(比如间距比例、尺寸换算、颜色调整等),可以被统一封装,而不是在各处重复书写。

/* 返回带透明度的颜色 */
@function --opacity(--color, --opacity) {
    result: rgb(from var(--color) r g b / var(--opacity));
}
ul {
    --brand: #f50;
  
    li {
        background: --opacity(var(--brand), calc(sibling-index() * .1));
    }
}

Demo 地址:codepen.io/airen/full/…

这种能力的关键在于,它让 CSS 开始具备逻辑抽象与复用能力。你不再只是声明结果,而是可以定义“如何得到这个结果”。对于设计系统来说,这尤为重要——许多设计规则本质上都是一套可复用的计算逻辑,而 @function 正好提供了表达这些规则的原生方式。

更进一步,当 @function 与 CSS 自定义属性、if() 条件函数以及容器查询等能力结合使用时,CSS 的表达力会进一步增强。你可以在样式层完成从“输入 → 计算 → 输出”的完整流程,让组件具备更强的自适应能力,而无需依赖 JavaScript。

这也意味着,CSS 正在从“样式描述语言”逐步演进为一种具备轻量逻辑能力的系统,让更多原本属于 JavaScript 或预处理器的工作,回归到原生平台中完成。

温馨提示:如果你想更深入了解 @function 的语法细节、使用方式以及在实际项目中的应用场景,可以移步阅读《Web UI:CSS 中的自定义函数 @function》和《CSS 技巧:5 个实用 CSS 自定义函数》,进一步探索这一特性的更多可能性与实践价值。

小结

如果把这些能力放在一起重新审视,你会发现一个非常清晰的趋势——CSS 正在从一个“被动描述样式”的语言,逐步演变为一个同时具备布局、交互,甚至一定逻辑能力的系统。它不再只是用来“画界面”,而是在参与界面的行为定义。

从锚点定位、Popover,到滚动驱动动画、视图过渡,再到 if()@function 这样的条件与计算能力,这些特性并不是零散出现的,而是在系统性地填补过去长期依赖 JavaScript 的空白。浏览器开始在更底层、更高效的层级处理 UI,而不是把所有控制权都交给运行在主线程上的脚本。

这带来的变化,也不仅仅是“少写一点 JavaScript”。更深层的影响在于:你可以用更少的代码实现更复杂的效果,同时获得更好的性能表现,因为许多计算与渲染发生在浏览器内部;组件的实现也更加稳定和可维护,不再需要手动处理大量边界情况或同步状态。

当然,这并不意味着 CSS 会完全取代 JavaScript。一些复杂交互,例如拖拽(drag & drop)、手势控制或高度动态的业务逻辑,仍然属于 JavaScript 的范畴。但可以确定的是,这条边界正在快速移动,而且比很多人想象得更快。

也正因为如此,现在是一个重新思考前端实现方式的好时机。当你准备写一段 JavaScript 来“控制 UI”的时候,不妨先停一下,问自己一个问题:CSS,现在是不是已经可以做到? 很多时候,答案已经和过去截然不同。

❌