跳至内容
·12 分钟阅读

2026 年的现代 CSS:彻底改变我写样式方式的特性

容器查询、CSS 层叠层、:has()、color-mix()、嵌套、滚动驱动动画和锚点定位。这些 CSS 特性让我彻底告别了用 JavaScript 来做样式的日子。

分享:X / TwitterLinkedIn

六个月前,我删掉了最后一个 Sass 文件。不是为了表态,而是因为我真的不再需要它了。

十多年来,CSS 一直是那个我们不好意思提的语言。嵌套和变量要靠预处理器,基于容器的尺寸、滚动联动动画、父元素选择以及设计师交给我们的一半布局方案都要靠 JavaScript。我们构建了一整套运行时系统——CSS-in-JS 库、原子类框架、PostCSS 插件链——来弥补这门语言原生做不到的事情。

那个时代结束了。不是「快要结束」,不是「快了」,而是彻底结束了。

2024 年到 2026 年间浏览器落地的特性,不仅仅是锦上添花,而是改变了整个思维模式。CSS 不再是一门你需要跟它较劲的样式语言,它现在真正理解了组件、特指度管理、元素级的响应式设计,以及无需运行时的动画。

下面就来看看到底发生了什么变化,为什么重要,以及你因此可以不再做哪些事了。

容器查询:告别「视口有多宽?」#

这是从根本上改变了我对响应式设计思维方式的特性。二十年来我们有媒体查询。媒体查询问的是:「视口有多宽?」而容器查询问的是:「这个组件所在的容器有多宽?」

这个区别听起来很微妙,但它一点都不微妙。这是组件只能在某一种布局上下文中工作和组件在任何地方都能工作之间的区别。

存在了二十年的问题#

想一想卡片组件。在侧边栏里,它应该垂直堆叠并配一张小图。在主内容区域里,它应该水平排列并配一张大图。在全宽 hero 区域里,它又该是另外一种样子。

用媒体查询的话,你大概会这么写:

css
/* The old way: coupling component styles to page layout */
.card {
  display: flex;
  flex-direction: column;
}
 
@media (min-width: 768px) {
  .sidebar .card {
    /* Still vertical in sidebar */
  }
 
  .main-content .card {
    flex-direction: row;
    /* Horizontal in main area */
  }
}
 
@media (min-width: 1200px) {
  .hero .card {
    /* Yet another layout */
  }
}

这个卡片知道 .sidebar.main-content.hero 的存在。它了解页面的结构。它不再是一个组件——而是一个感知页面的碎片。把它放到另一个页面上,一切都会崩溃。

容器查询彻底解决了这个问题#

css
/* The container query way: component knows only about itself */
.card-wrapper {
  container-type: inline-size;
  container-name: card;
}
 
.card {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1rem;
}
 
@container card (min-width: 400px) {
  .card {
    grid-template-columns: 200px 1fr;
  }
}
 
@container card (min-width: 700px) {
  .card {
    grid-template-columns: 300px 1fr;
    gap: 2rem;
    font-size: 1.125rem;
  }
}

卡片不知道自己在哪里,也不在乎。放在 300px 的侧边栏里它就是垂直的,放在 700px 的主内容区域里它就是水平的,放在全宽区域里它自动适配。完全不需要了解页面布局。

inline-sizesize#

你几乎总是会用 container-type: inline-size。它启用的是内联轴(水平书写模式下的宽度)上的查询。使用 container-type: size 可以同时启用内联轴和块轴查询,但它要求容器在两个维度上都有明确的尺寸,在大多数情况下这会破坏正常的文档流。

css
/* This is what you want 99% of the time */
.wrapper {
  container-type: inline-size;
}
 
/* This requires explicit height — rarely what you want */
.wrapper-both {
  container-type: size;
  height: 500px; /* required, or it collapses */
}

嵌套容器的命名#

当容器发生嵌套时,命名就变得至关重要:

css
.page-layout {
  container-type: inline-size;
  container-name: layout;
}
 
.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}
 
/* Target the sidebar specifically, not the nearest ancestor */
@container sidebar (max-width: 250px) {
  .nav-item {
    font-size: 0.875rem;
    padding: 0.25rem;
  }
}
 
/* Target the page layout */
@container layout (min-width: 1200px) {
  .page-header {
    font-size: 2.5rem;
  }
}

不加名字的话,@container 查询的是最近的有包含性的祖先元素。在嵌套的情况下,那往往不是你想要的容器。给它们命名,永远如此。

容器查询单位#

这一点被低估了。容器查询单位(cqicqbcqwcqh)让你可以相对于容器而非视口来调整尺寸:

css
@container (min-width: 400px) {
  .card-title {
    font-size: clamp(1rem, 4cqi, 2rem);
  }
}

4cqi 是容器内联尺寸的 4%。标题随容器缩放,而不是随窗口缩放。这才是流体排版从一开始就应该有的样子。

CSS 层叠层:特指度大战结束了#

如果说容器查询改变了我对响应式设计的思考方式,那么 @layer 就改变了我对 CSS 架构的思考方式。这是我们第一次有了一种合理的、声明式的方式来管理整个项目的特指度。

问题所在#

CSS 特指度是一个不关心你意图的计分系统。一个 .text-red 的工具类会输给 .card .title,因为后者的特指度更高。解决方法总是一样的:让你的选择器更具体,加 !important,或者重新组织一切。

我们为了避免特指度冲突,发明了完整的方法论(BEM、SMACSS、ITCSS)和工具链。这一切都是因为语言缺少一个特性而做的变通方案。

层叠顺序#

@layer 让你声明样式组被考虑的顺序,不管这些组内部的特指度如何:

css
/* Declare layer order — this single line controls everything */
@layer reset, base, components, utilities, overrides;
 
@layer reset {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
}
 
@layer base {
  body {
    font-family: system-ui, sans-serif;
    line-height: 1.6;
    color: oklch(20% 0 0);
  }
 
  h1, h2, h3 {
    line-height: 1.2;
    text-wrap: balance;
  }
}
 
@layer components {
  .card {
    background: white;
    border-radius: 0.5rem;
    padding: 1.5rem;
    box-shadow: 0 1px 3px oklch(0% 0 0 / 0.12);
  }
 
  .card .title {
    font-size: 1.25rem;
    font-weight: 600;
    color: oklch(25% 0.05 260);
  }
}
 
@layer utilities {
  .text-red {
    color: oklch(55% 0.25 25);
  }
 
  .hidden {
    display: none;
  }
 
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
  }
}

尽管 .card .title 的特指度高于 .text-red,但工具类会胜出,因为 utilities 层被声明在 components 之后。不需要 !important,不需要特指度 hack。层叠顺序就是最终裁决。

Tailwind CSS v4 如何使用层叠层#

Tailwind v4 大量依赖 @layer。当你写 @import "tailwindcss" 时,你得到的是:

css
@layer theme, base, components, utilities;

每个 Tailwind 工具类都在 utilities 层中。你自定义的组件样式放在 components 层中。所以 text-red-500 类可以覆盖组件的颜色而不需要 !important——因为它在更靠后的层中。

如果你在不用 Tailwind 的情况下构建自己的设计系统,可以借鉴这个架构,它是正确的:

css
@layer reset, tokens, base, layouts, components, utilities, overrides;

七层足够了。我从来没有需要更多的。

未分层的样式优先级最高#

一个需要注意的地方:不在任何层中的样式拥有最高优先级。这其实是有用的——意味着你的一次性页面特定覆盖会自动胜出:

css
@layer components {
  .modal {
    background: white;
  }
}
 
/* Not in any layer — wins over everything in layers */
.special-page .modal {
  background: oklch(95% 0.02 260);
}

但这也意味着不了解层叠层的第三方 CSS 可以覆盖你的整个系统。把第三方样式包裹在一个层中来控制它们:

css
@layer third-party {
  @import url("some-library.css");
}

:has() 选择器:我们一直梦想的父元素选择器#

几十年来,开发者一直在要求父元素选择器。「我想根据子元素来给父元素设置样式。」答案总是「CSS 做不到」,然后是一个 JavaScript 的变通方案。:has() 彻底改变了这一切,而且它比我们要求的还要强大。

基本的父元素选择#

css
/* Style a form group when its input has focus */
.form-group:has(input:focus) {
  border-color: oklch(55% 0.2 260);
  box-shadow: 0 0 0 3px oklch(55% 0.2 260 / 0.15);
}
 
/* Style a card differently when it contains an image */
.card:has(img) {
  padding-top: 0;
}
 
.card:has(img) .card-content {
  padding: 1.5rem;
}
 
/* A card without an image gets different treatment */
.card:not(:has(img)) {
  border-left: 4px solid oklch(55% 0.2 260);
}

无需 JavaScript 的表单验证状态#

这就是 :has() 真正让人兴奋的地方。结合 HTML 验证伪类,你可以完全不用 JavaScript 就构建出能响应验证状态的表单 UI:

css
/* The field wrapper reacts to its input's validity */
.field:has(input:invalid:not(:placeholder-shown)) {
  --field-color: oklch(55% 0.25 25);
}
 
.field:has(input:valid:not(:placeholder-shown)) {
  --field-color: oklch(55% 0.2 145);
}
 
.field {
  --field-color: oklch(70% 0 0);
  border: 2px solid var(--field-color);
  border-radius: 0.5rem;
  padding: 0.75rem;
  transition: border-color 0.2s;
}
 
.field label {
  color: var(--field-color);
  font-size: 0.875rem;
  font-weight: 500;
}
 
.field .error-message {
  display: none;
  color: oklch(55% 0.25 25);
  font-size: 0.8rem;
  margin-top: 0.25rem;
}
 
.field:has(input:invalid:not(:placeholder-shown)) .error-message {
  display: block;
}

:not(:placeholder-shown) 这部分很关键——它防止验证样式出现在还没被触碰的空字段上。

基于子元素数量的布局#

这个模式非常实用,在 :has() 出现之前确实是不可能做到的:

css
/* Adjust grid columns based on how many items exist */
.grid-auto:has(> :nth-child(4)) {
  grid-template-columns: repeat(2, 1fr);
}
 
.grid-auto:has(> :nth-child(7)) {
  grid-template-columns: repeat(3, 1fr);
}
 
.grid-auto:has(> :nth-child(13)) {
  grid-template-columns: repeat(4, 1fr);
}

网格根据子元素的数量改变列数。不需要 JavaScript,不需要 ResizeObserver,不需要切换 class。

侧边栏检测#

这是我最喜欢的模式之一——根据侧边栏是否存在来改变布局:

css
.page-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem;
}
 
/* If the layout contains a sidebar, switch to two columns */
.page-layout:has(.sidebar) {
  grid-template-columns: 1fr 300px;
}
 
/* Adjust main content width when sidebar is present */
.page-layout:has(.sidebar) .main-content {
  max-width: 65ch;
}

在 DOM 中添加一个侧边栏组件,布局自动调整。移除它,又恢复原样。CSS 就是真相来源,而不是某个状态变量。

:has() 与其他选择器组合#

:has() 与其他选择器组合得非常优雅:

css
/* Style an article only when it has a specific class of figure */
article:has(figure.full-bleed) {
  overflow: visible;
}
 
/* A navigation that changes when it contains a search input */
nav:has(input[type="search"]:focus) {
  background: oklch(98% 0 0);
  box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
 
/* Enable dark-mode at the component level */
.theme-switch:has(input:checked) ~ main {
  color-scheme: dark;
  background: oklch(15% 0 0);
  color: oklch(90% 0 0);
}

浏览器兼容性现在已经非常好了。从 2024 年初开始,所有现代浏览器都支持 :has()。没有理由不使用它。

CSS 嵌套:原生支持,终于稳定了#

我不打算过度吹嘘这个。CSS 嵌套挺好的,但它不像容器查询或 :has() 那样具有革命性。不过,它去掉了使用 Sass 的最后理由之一,这很重要。

语法#

css
.card {
  background: white;
  border-radius: 0.5rem;
  padding: 1.5rem;
 
  .title {
    font-size: 1.25rem;
    font-weight: 600;
    margin-bottom: 0.5rem;
  }
 
  .description {
    color: oklch(40% 0 0);
    line-height: 1.7;
  }
 
  &:hover {
    box-shadow: 0 4px 12px oklch(0% 0 0 / 0.1);
  }
 
  &.featured {
    border: 2px solid oklch(60% 0.2 260);
  }
}

这是合法的 CSS。不需要构建步骤,不需要预处理器,不需要 PostCSS 插件。浏览器原生处理它。

与 Sass 的区别#

有几个语法差异值得了解:

css
/* CSS Nesting — works now in all browsers */
.parent {
  /* Direct class/element nesting works without & */
  .child {
    color: red;
  }
 
  /* & is required for pseudo-classes and compound selectors */
  &:hover {
    background: blue;
  }
 
  &.active {
    font-weight: bold;
  }
 
  /* Nested media queries — this is great */
  @media (width >= 768px) {
    flex-direction: row;
  }
}

在早期实现中,你需要在元素选择器前加 &(例如 & p 而不是 p)。这个限制已经放宽了。从 2025 年开始,所有主流浏览器都支持裸元素嵌套:.parent { p { ... } } 正常工作。

嵌套媒体查询#

在我看来,这才是 CSS 嵌套的杀手级特性。不是选择器嵌套——而是能把媒体查询放在规则块内部:

css
.hero {
  padding: 2rem;
  font-size: 1rem;
 
  @media (width >= 768px) {
    padding: 4rem;
    font-size: 1.25rem;
  }
 
  @media (width >= 1200px) {
    padding: 6rem;
    font-size: 1.5rem;
  }
}

对比以前的写法,.hero 的样式分散在三个不同的 @media 块中,可能相隔数百行。嵌套让响应式行为和组件放在一起。可读性大大提高。

不要过度嵌套#

一个警告:你嵌套六层深并不意味着你应该这么做。Sass 的那些建议在这里同样适用。如果你的嵌套创建了像 .page .section .card .content .text .highlight 这样的选择器,你就制造了一个特指度怪物和维护噩梦。两到三层是最佳选择。

css
/* Good — two levels */
.nav {
  .link {
    color: inherit;
 
    &:hover {
      color: oklch(55% 0.2 260);
    }
  }
}
 
/* Bad — specificity nightmare */
.page {
  .layout {
    .sidebar {
      .nav {
        .list {
          .item {
            .link {
              color: red; /* good luck overriding this */
            }
          }
        }
      }
    }
  }
}

颜色函数:oklch()color-mix() 改变了一切#

hsl() 风光了很久。它比 rgb() 更直观。但它有一个根本性缺陷:它在感知上不是均匀的。hsl(60, 100%, 50%)(黄色)在人眼看来比 hsl(240, 100%, 50%)(蓝色)明显更亮,尽管它们的亮度值是一样的。

为什么 oklch() 更胜一筹#

oklch() 在感知上是均匀的。相同的亮度值看起来一样亮。这在生成调色板、创建主题和确保无障碍对比度时极其重要:

css
:root {
  /* oklch(lightness chroma hue) */
  --color-primary: oklch(55% 0.2 260);     /* blue */
  --color-secondary: oklch(55% 0.2 330);   /* purple */
  --color-success: oklch(55% 0.2 145);     /* green */
  --color-danger: oklch(55% 0.25 25);      /* red */
  --color-warning: oklch(75% 0.18 85);     /* yellow — higher L for equal perception */
 
  /* These all look equally "medium" to the human eye */
  /* With HSL, you'd need different lightness values for each hue */
}

这三个值一旦理解就很直观:

  • 亮度(0% 到 100%):多亮或多暗。0% 是黑色,100% 是白色。
  • 色度(0 到约 0.37):多鲜艳。0 是灰色,越高越饱和。
  • 色相(0 到 360):色彩角度。0/360 是偏粉红,145 是绿色,260 是蓝色。

color-mix() 生成派生颜色#

color-mix() 让你可以在运行时从其他颜色创建新颜色。不需要 Sass 的 darken() 函数,不需要 JavaScript,纯 CSS:

css
:root {
  --brand: oklch(55% 0.2 260);
 
  /* Lighten by mixing with white */
  --brand-light: color-mix(in oklch, var(--brand) 30%, white);
 
  /* Darken by mixing with black */
  --brand-dark: color-mix(in oklch, var(--brand) 70%, black);
 
  /* Create a subtle background */
  --brand-bg: color-mix(in oklch, var(--brand) 8%, white);
 
  /* Semi-transparent version */
  --brand-overlay: color-mix(in oklch, var(--brand) 50%, transparent);
}
 
.button {
  background: var(--brand);
  color: white;
 
  &:hover {
    background: var(--brand-dark);
  }
 
  &:active {
    background: color-mix(in oklch, var(--brand) 60%, black);
  }
}
 
.button.secondary {
  background: var(--brand-bg);
  color: var(--brand-dark);
 
  &:hover {
    background: color-mix(in oklch, var(--brand) 15%, white);
  }
}

in oklch 这部分很重要。在 srgb 中混合会产生浑浊的中间颜色,在 oklch 中混合则能得到感知上均匀的结果。始终在 oklch 中混合。

oklch() 构建完整调色板#

下面是我如何从单一色相生成完整的色阶调色板:

css
:root {
  --hue: 260;
  --chroma: 0.2;
 
  --color-50:  oklch(97% calc(var(--chroma) * 0.1) var(--hue));
  --color-100: oklch(93% calc(var(--chroma) * 0.2) var(--hue));
  --color-200: oklch(85% calc(var(--chroma) * 0.4) var(--hue));
  --color-300: oklch(75% calc(var(--chroma) * 0.6) var(--hue));
  --color-400: oklch(65% calc(var(--chroma) * 0.8) var(--hue));
  --color-500: oklch(55% var(--chroma) var(--hue));
  --color-600: oklch(48% var(--chroma) var(--hue));
  --color-700: oklch(40% calc(var(--chroma) * 0.9) var(--hue));
  --color-800: oklch(32% calc(var(--chroma) * 0.8) var(--hue));
  --color-900: oklch(25% calc(var(--chroma) * 0.7) var(--hue));
  --color-950: oklch(18% calc(var(--chroma) * 0.5) var(--hue));
}

--hue 改成 145 就是绿色调色板,改成 25 就是红色。亮度阶梯在感知上是均匀的。色度在极端值处逐渐收敛,这样最亮和最暗的色阶就不会过饱和。这种事以前需要设计工具或 Sass 函数,现在只需八行 CSS。

滚动驱动动画:不需要 JavaScript#

这是让我删掉最多 JavaScript 的特性。滚动联动动画——进度条、视差效果、淡入动画、带过渡效果的固定头部——以前需要 IntersectionObserverscroll 事件监听器,或者 GSAP 这样的库。现在用 CSS 就行了。

滚动进度指示器#

经典的文章页面顶部「阅读进度条」:

css
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  background: oklch(55% 0.2 260);
  transform-origin: left;
  z-index: 1000;
 
  animation: grow-progress linear both;
  animation-timeline: scroll();
}
 
@keyframes grow-progress {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

就这样,完整的阅读进度指示器。不需要 JavaScript,不需要 scroll 事件监听器,不需要 requestAnimationFrame,不需要「已滚动百分比」的计算。animation-timeline: scroll() 绑定搞定了一切。

滚动时淡入#

元素在进入视口时淡入:

css
.reveal {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
 
@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

animation-timeline: view() 将动画绑定到元素在视口中的可见性。animation-range: entry 0% entry 100% 意味着动画从元素开始进入视口的那一刻运行,直到它完全可见。

无需库的视差效果#

css
.parallax-bg {
  animation: parallax linear both;
  animation-timeline: scroll();
}
 
@keyframes parallax {
  from {
    transform: translateY(-20%);
  }
  to {
    transform: translateY(20%);
  }
}
 
.parallax-section {
  overflow: hidden;
  position: relative;
}

背景图片以不同于滚动的速率移动,产生视差效果。流畅、高性能(浏览器可以在 GPU 上合成),且零 JavaScript。

命名滚动时间线#

要更精细地控制,你可以命名滚动时间线并从其他元素引用它:

css
.scroller {
  overflow-y: auto;
  scroll-timeline-name: --my-scroller;
  scroll-timeline-axis: block;
}
 
/* An element anywhere in the DOM can reference this timeline */
.indicator {
  animation: progress linear both;
  animation-timeline: --my-scroller;
}
 
@keyframes progress {
  from { width: 0%; }
  to { width: 100%; }
}

即使指示器和滚动容器不是父子关系也能工作。任何元素都可以链接到任何命名的滚动时间线。这对仪表板 UI 非常强大——一个可滚动面板可以驱动固定头部中的指示器。

锚点定位:正确实现工具提示和弹出框#

在锚点定位出现之前,将工具提示连接到它的触发元素需要 JavaScript。你得用 getBoundingClientRect() 计算位置、处理滚动偏移、管理视口碰撞,并在窗口调整大小时重新计算。Popper.js(现在的 Floating UI)这样的库之所以存在,就是因为这件事太难做对了。

CSS 锚点定位让这变成了声明式的:

css
.trigger {
  anchor-name: --my-trigger;
}
 
.tooltip {
  position: fixed;
  position-anchor: --my-trigger;
 
  /* Position the tooltip's top-center at the trigger's bottom-center */
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
 
  /* Fallback positioning if it overflows the viewport */
  position-try-fallbacks: flip-block, flip-inline;
}

自动视口碰撞处理#

position-try-fallbacks 属性是以前需要 200 行 JavaScript 的部分。它告诉浏览器:「如果工具提示在底部溢出视口,就翻转到顶部。如果在右侧溢出,就翻转到左侧。」

css
.dropdown-menu {
  position: fixed;
  position-anchor: --menu-button;
 
  /* Default: below the button, aligned to the left edge */
  top: anchor(bottom);
  left: anchor(left);
 
  /* If it doesn't fit below, try above. If it doesn't fit left-aligned, try right-aligned */
  position-try-fallbacks: flip-block, flip-inline;
 
  /* Add a gap between the anchor and the dropdown */
  margin-top: 4px;
}

自定义位置回退#

要更精细地控制回退位置,你可以定义自定义 try 选项:

css
@position-try --above-right {
  bottom: anchor(top);
  right: anchor(right);
  top: auto;
  left: auto;
  margin-top: 0;
  margin-bottom: 4px;
}
 
@position-try --left-center {
  right: anchor(left);
  top: anchor(center);
  left: auto;
  bottom: auto;
  translate: 0 -50%;
  margin-top: 0;
  margin-right: 4px;
}
 
.tooltip {
  position: fixed;
  position-anchor: --trigger;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
  margin-top: 4px;
 
  position-try-fallbacks: --above-right, --left-center;
}

浏览器会按顺序尝试每个回退,直到找到一个能让元素保持在视口内的方案。这种空间推理在 JavaScript 中确实很痛苦。

锚点定位与 Popover API 配合#

锚点定位与新的 Popover API 完美搭配:

html
<button popovertarget="my-popover" style="anchor-name: --btn">Settings</button>
 
<div popover id="my-popover" style="
  position-anchor: --btn;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
  margin-top: 8px;
">
  Popover content here
</div>

不需要 JavaScript 来显示/隐藏(Popover API 处理),不需要 JavaScript 来定位(锚点定位处理),不需要 JavaScript 来实现轻触关闭行为(Popover API 也处理了)。整个工具提示/弹出框/下拉菜单的模式——一个曾经支撑了大量 npm 包的模式——现在只需要 HTML 和 CSS。

CSS Grid Subgrid:嵌套对齐终于好用了#

Grid 很强大,但它有一个令人沮丧的限制:子网格无法将其项目对齐到父网格。如果你有一行卡片,想让每张卡片的标题、内容和底部跨卡片对齐,那就没辙了。每张卡片的内部网格是独立的。

Subgrid 解决了这个问题。

卡片对齐问题#

css
/* Parent grid */
.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.5rem;
}
 
/* Each card becomes a subgrid, aligning rows to the parent */
.card {
  display: grid;
  grid-row: span 3; /* Card spans 3 rows in the parent */
  grid-template-rows: subgrid;
  gap: 0; /* Card controls its own internal gap */
}
 
.card-title {
  font-weight: 600;
  padding: 1rem 1rem 0.5rem;
}
 
.card-body {
  padding: 0 1rem;
  color: oklch(40% 0 0);
}
 
.card-footer {
  padding: 0.5rem 1rem 1rem;
  margin-top: auto;
  border-top: 1px solid oklch(90% 0 0);
}

现在所有三张卡片的标题在同一行对齐。正文对齐。底部对齐。即使一张卡片的标题有两行而另一张只有一行,对齐也由父网格维护。

Subgrid 之前的 Hack#

没有 subgrid 时,要实现这种对齐需要:

  1. 固定高度(脆弱,动态内容会打破它)
  2. JavaScript 测量(慢,会闪一下对不齐的画面)
  3. 放弃对齐(常见,但丑)
css
/* The old hack — fragile and breaks with dynamic content */
.card-title {
  min-height: 3rem; /* pray that no title exceeds this */
}
 
.card-body {
  min-height: 8rem; /* even more prayer */
}

Subgrid 让这个 hack 变得多余。父网格根据每一行中所有卡片中最高的内容来分配行高。

列 Subgrid 用于表单布局#

Subgrid 也可以用在列上,非常适合表单布局:

css
.form {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.75rem 1rem;
}
 
.form-row {
  display: grid;
  grid-column: 1 / -1;
  grid-template-columns: subgrid;
  align-items: center;
}
 
.form-row label {
  grid-column: 1;
  text-align: right;
  font-weight: 500;
}
 
.form-row input {
  grid-column: 2;
  padding: 0.5rem;
  border: 1px solid oklch(80% 0 0);
  border-radius: 0.375rem;
}

所有标签对齐。所有输入框对齐。标签列自动适应最宽的标签。不需要硬编码宽度。

View Transitions API:无需框架的 SPA 级导航体验#

这是本文列表中最大胆的特性。View Transitions API 让你可以在页面导航之间添加动画——包括跨文档导航(多页应用中的普通链接点击)。你的静态 HTML 站点现在也能拥有流畅的页面过渡动画了。

跨文档过渡#

要启用跨文档视图过渡,只需在新旧两个页面上添加一条 CSS 规则:

css
@view-transition {
  navigation: auto;
}

就这样。浏览器现在会在页面导航时进行交叉淡入淡出。默认过渡是平滑的透明度渐变。不需要 JavaScript,不需要框架,只需两行 CSS 写在每个页面上。

自定义过渡效果#

你可以通过命名特定元素来自定义过渡效果:

css
/* On both pages */
.page-header {
  view-transition-name: header;
}
 
.main-content {
  view-transition-name: content;
}
 
.hero-image {
  view-transition-name: hero;
}
 
/* Transition animations */
::view-transition-old(content) {
  animation: slide-out 0.3s ease-in both;
}
 
::view-transition-new(content) {
  animation: slide-in 0.3s ease-out both;
}
 
::view-transition-old(hero) {
  animation: fade-out 0.2s ease-in both;
}
 
::view-transition-new(hero) {
  animation: none; /* The new hero just appears */
}
 
@keyframes slide-out {
  to {
    transform: translateX(-100%);
    opacity: 0;
  }
}
 
@keyframes slide-in {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
}
 
@keyframes fade-out {
  to {
    opacity: 0;
  }
}

当在两个都有 .hero-image 且设置了 view-transition-name: hero 的页面之间导航时,浏览器会自动将图片从旧页面的位置动画到新页面的位置。这就是移动端开发中的「共享元素过渡」模式,现在在浏览器中也有了。

SPA 视图过渡#

对于单页应用(React、Vue、Svelte 等),JavaScript API 很直接:

css
/* CSS side — define your transition animations */
::view-transition-old(root) {
  animation: fade-out 0.2s ease-in;
}
 
::view-transition-new(root) {
  animation: fade-in 0.2s ease-out;
}
javascript
// JS side — wrap your DOM update in startViewTransition
document.startViewTransition(() => {
  // Update the DOM here — React render, innerHTML swap, etc.
  updateContent(newPageData);
});

浏览器先快照旧状态,执行你的更新,快照新状态,然后在两者之间添加动画。命名元素获得单独的过渡效果;其他所有内容获得默认的交叉淡入淡出。

尊重用户偏好#

始终尊重 prefers-reduced-motion

css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

你可以不再使用的东西#

这些特性加在一起,淘汰了大量我们多年来依赖的工具、库和模式。以下是我已经移除或不再使用的东西:

Sass/SCSS 的嵌套和变量#

CSS 有了原生嵌套。CSS 有了自定义属性(而且已经有好几年了)。人们使用 Sass 的两个主要原因现在都已经在语言中了。如果你使用 Sass 只是为了 $variables 和嵌套,你可以停了。

css
/* Before: Sass */
$primary: #3b82f6;
 
.card {
  background: white;
  border: 1px solid lighten($primary, 40%);
 
  &:hover {
    border-color: $primary;
  }
 
  .title {
    color: darken($primary, 15%);
  }
}
 
/* After: Native CSS */
.card {
  --primary: oklch(55% 0.2 260);
 
  background: white;
  border: 1px solid color-mix(in oklch, var(--primary) 20%, white);
 
  &:hover {
    border-color: var(--primary);
  }
 
  .title {
    color: color-mix(in oklch, var(--primary) 70%, black);
  }
}

CSS 版本更强大——oklch 提供了感知均匀的颜色操作,color-mix 在运行时工作(Sass lighten 仅在编译时),自定义属性可以从 JavaScript 或媒体查询动态修改。

滚动动画的 JavaScript#

删掉你的 IntersectionObserver 滚动淡入代码,删掉你的滚动进度条 JavaScript,删掉你的视差滚动处理器。animation-timeline: scroll()animation-timeline: view() 以更好的性能(合成线程,不是主线程)处理所有这些。

javascript
// Before: JavaScript you can delete
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
        observer.unobserve(entry.target);
      }
    });
  },
  { threshold: 0.1 }
);
 
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
css
/* After: CSS that replaces all of the above */
.reveal {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
 
@keyframes reveal {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

Popper.js / Floating UI 做定位#

如果你使用 Floating UI(前身是 Popper.js)只是为了基本的工具提示/弹出框定位,CSS 锚点定位可以替代它。你会失去一些高级功能(虚拟元素、自定义中间件),但对于 90% 的用例——「把这个弹出框放在那个按钮旁边并保持在视口内」——CSS 原生就能做了。

margin: auto 居中 Hack#

这不是新事物,但我仍然到处看到 margin: 0 auto。现代布局工具在大多数情况下让它变得不必要了:

css
/* Old way */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
}
 
/* Better: CSS Grid or Flexbox */
.page {
  display: grid;
  place-items: center;
}
 
/* Or: container queries make the container itself responsive */
.content {
  container-type: inline-size;
  width: min(100% - 2rem, 1200px);
  margin-inline: auto; /* OK, still auto, but margin-inline is clearer */
}

margin: 0 auto 仍然好用。但如果你发现自己用它来做垂直居中或复杂对齐,还是用 flexbox 或 grid 吧。

用媒体查询做组件级响应式#

这是最重要的一个。如果你还在写 @media 查询来让组件响应式,那你现在大概率做错了。容器查询应该是你组件级响应式的默认选择。@media 查询留给页面级决策:布局变化、导航模式、打印样式。

css
/* Before: Media query for component styling (wrong scope) */
@media (min-width: 768px) {
  .product-card {
    flex-direction: row;
  }
}
 
/* After: Container query (right scope) */
.product-card-wrapper {
  container-type: inline-size;
}
 
@container (min-width: 400px) {
  .product-card {
    flex-direction: row;
  }
}

更大的图景#

这些特性不是孤立存在的,它们可以组合。容器查询 + :has() + 嵌套 + oklch() + 层叠层——组合使用时——给你的 CSS 编写体验是五年前不可想象的:

css
@layer components {
  .card-wrapper {
    container-type: inline-size;
  }
 
  .card {
    --accent: oklch(55% 0.2 260);
 
    display: grid;
    gap: 1rem;
    padding: 1.5rem;
    border-radius: 0.5rem;
    background: white;
    border: 1px solid oklch(90% 0 0);
 
    &:has(img) {
      padding-top: 0;
 
      img {
        border-radius: 0.5rem 0.5rem 0 0;
        width: 100%;
        aspect-ratio: 16 / 9;
        object-fit: cover;
      }
    }
 
    &:has(.badge.new) {
      border-color: var(--accent);
    }
 
    .title {
      font-size: 1.125rem;
      font-weight: 600;
      color: oklch(20% 0 0);
      text-wrap: balance;
    }
 
    .meta {
      font-size: 0.875rem;
      color: oklch(50% 0 0);
    }
 
    @container (min-width: 500px) {
      grid-template-columns: 200px 1fr;
 
      &:has(img) {
        padding: 0;
 
        img {
          border-radius: 0.5rem 0 0 0.5rem;
          height: 100%;
          aspect-ratio: auto;
        }
      }
 
      .content {
        padding: 1.5rem;
      }
    }
  }
}

这一个块就处理了:

  • 组件级响应式布局(容器查询)
  • 基于内容的条件样式(:has()
  • 清晰的选择器组织(嵌套)
  • 感知均匀的颜色(oklch
  • 可预测的特指度管理(@layer

不需要预处理器。不需要 CSS-in-JS 运行时。不需要工具类。只是 CSS,做着 CSS 一直应该做的事情。

平台追上来了。终于。而且值得等待。

相关文章