如果你拆开一个稍微复杂一点的 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-area 和 position-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-try、position-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 属性则支持在内联关键词尺寸(如 auto 或 min-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。
details 和 summary
原生 <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() 搭配 input(checkbox / radio)实现状态驱动的切换,使用 <dialog> 构建模态交互,通过 Popover API 控制浮层显示,以及 <details> 和<summary> 实现折叠内容等。
这些方案不仅实现更简单,而且更加语义化、可维护,并且在可访问性方面由浏览器原生保障。如果你对这一类“用 CSS 替代 JavaScript”的实现方式感兴趣,可以进一步阅读《CSS 布局:切换内容的现代方式》深入了解。
滚动驱动动画
在过去,所谓“滚动驱动动画”,几乎等同于一套固定模式。用 JavaScript 监听 scroll 事件,配合 requestAnimationFrame ,在每一帧中读取 window.scrollY ,再去更新 CSS 自定义属性或 transform 、opacity 等属性值。随着页面复杂度的提升,这种做法很容易带来性能问题——频繁的主线程计算、布局抖动,以及难以维护的同步逻辑。像 GSAP ScrollTrigger 这样的库,本质上就是为了让这种模式更可控、更易管理而存在。
而现在,CSS 正在接管这一切。随着滚动驱动动画规范的引入,你可以通过 animation-timeline (scroll() 或 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> 组件,本质上都在帮你管理一套流程:克隆元素、设置绝对定位、同步多个动画状态,并手动控制 opacity 和 transform 的时间轴。这不仅实现成本高,而且维护起来也相当繁琐。
而 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 事件、判断 ArrowRight 、 ArrowLeft 、 ArrowUp 、 ArrowDown,手动维护 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 还提供了一些可选配置,用来微调导航行为。例如,可以通过 inline 或 block 限制导航方向(仅水平或垂直),使用 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 之瀑布流布局:masonry 和 masonry-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:CSSif语句与条件逻辑》和《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,现在是不是已经可以做到? 很多时候,答案已经和过去截然不同。