过去写响应式布局,思路是「用媒体查询判断屏幕宽度,然后调整组件样式」。问题是:同一个卡片组件,放在侧边栏和放在主区域,宽度完全不同,但媒体查询看到的视口是一样的。

容器查询解决了这个问题。

一、容器查询:组件感知自己的宽度

/* 1. 声明这个元素是「查询容器」 */
.card-wrap {
  container-type: inline-size;
  container-name: card;
}

/* 2. 查询容器宽度,而不是视口宽度 */
.card { display: flex; gap: 12px; }

@container card (max-width: 420px) {
  .card { flex-direction: column; }
  .card .thumb { width: 100%; }
}

效果:同一个组件,放在窄侧栏里自动变竖向,放在宽主区域里保持横向,不需要任何 JS,也不需要在父组件里传 isCompact 这类 prop。

浏览器支持已经很好了(Chrome 105+、Safari 16+、Firefox 110+)。

二、Grid 的三种高频模式

2.1 自适应卡片流(不用写媒体查询)

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
}

一行代码就实现了「每列最小 260px,能塞几列塞几列,剩余空间平均分配」。这是最值得记住的一条。

auto-fill 和 auto-fit 的区别:

  • auto-fill:列数按容器算,即使没有内容也保留空轨道
  • auto-fit:把空轨道折叠,内容会被拉伸铺满

只有一两个卡片时用 auto-fit,不然卡片会挤在左边。

2.2 圣杯布局

.layout {
  display: grid;
  grid-template-columns: 220px minmax(0, 1fr) 280px;
  grid-template-areas: "side main toc";
  gap: 28px;
}

@media (max-width: 900px) {
  .layout {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "main" "side" "toc";
  }
}

minmax(0, 1fr) 里的 0 很关键。写成 1fr 时,内容的 min-content 宽度会把列撑破——这就是「代码块把布局撑爆」的经典原因。

2.3 不用 gap 也能对齐的等高卡片

.row { display: grid; grid-auto-flow: column; gap: 12px; }
.row > * { display: grid; align-content: start; }

Grid 的默认行为就是同一行的子项等高,比 flex 少一层 align-items: stretch 的心智负担。

三、:has() 让父元素感知子元素

以前「父元素根据子元素状态变样式」必须靠 JS,现在:

/* 卡片里有图片时才加内边距 */
.card:has(img) { padding: 0; }

/* 表单里出现错误提示时,输入框变红 */
.field:has(.error) input { border-color: #f85149; }

/* 导航里当前项高亮时,给整个 header 加阴影 */
.header:has(.nav a[aria-current="page"]) { box-shadow: 0 2px 12px rgba(0,0,0,.2); }

/* 列表项被勾选后,文字加删除线 */
li:has(input[type="checkbox"]:checked) { text-decoration: line-through; opacity: .6; }

性能上,:has() 在现代浏览器里已经做了很好的优化。但如果选择器特别复杂又作用在长列表上,还是建议加个 class 更稳。

四、逻辑属性:一次写好,双向可用

margin-left 这类物理属性在 RTL 语言里会出问题。逻辑属性和书写方向绑定:

/* 旧写法 */
.element { margin-left: 12px; padding-right: 8px; }

/* 新写法 */
.element { margin-inline-start: 12px; padding-inline-end: 8px; }

配合 direction: rtl 或 writing-mode: vertical-rl,布局自动镜像,不用写第二套 CSS。

五、几个替代 JS 的小技巧

5.1 粘性表头 + 粘性首列

table { border-collapse: separate; border-spacing: 0; }
thead th { position: sticky; top: 0; z-index: 2; background: var(--surface); }
tbody th { position: sticky; left: 0; z-index: 1; background: var(--surface); }
thead th:first-child { z-index: 3; }   /* 左上角交叉处,层级最高 */

5.2 不用 JS 的折叠面板

<details>
  <summary>常见问题</summary>
  <p>答案在这里。</p>
</details>
details[open] summary { font-weight: 600; }

<details> 自带键盘可达性和无障碍语义,比手写 div + JS 好得多。

5.3 平滑滚动与滚动吸附

html { scroll-behavior: smooth; }

.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
.carousel > * { scroll-snap-align: start; flex: 0 0 80%; }

六、体积的取舍

现在很多框架项目里,CSS 的体积远小于 JS。一个纯 CSS 的响应式方案通常只有几 KB,而对应的 JS 版本要引入 ResizeObserver、状态管理、重新渲染。

判断标准很简单:这个交互需要保存状态吗?

  • 只影响样式的显隐/布局 → 纯 CSS(:has、容器查询、<details>)
  • 需要保存到服务端或影响业务数据 → JS

小结

三条能立刻用上的规则:

  1. 组件级响应式用容器查询,别再用视口媒体查询硬套
  2. 卡片流用 grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))
  3. 父元素感知子元素状态用 :has(),能省掉一大半状态提升

CSS 这几年进步很快,很多以前「必须上 JS」的场景,现在几行 CSS 就够了——而且更无障碍、更省性能。