想用CSS做选项卡切换,又不想写JS,这招details加Grid组合拳怎么样?

3,185字
13–20 分钟
in

用纯CSS实现选项卡切换,还能兼顾键盘操作和屏幕阅读器的体验,这在以前多少得整点“黑魔法”。现在不一样了,HTML自带的 <details> 元素加上CSS Grid布局,特别是子网格(Subgrid)这个特性,让这一切变得顺滑起来。不用JavaScript,代码结构清晰,而且天生就带一点可访问性支持,可以说是目前一个相当巧妙的解决方案。下面就把这套组合拳的完整搭建过程掰开揉碎了讲讲。

目录

结构,靠details撑场子

先把HTML的骨架搭起来。需要一组 <details> 元素,外面套一个容器,比如就叫 .grid。每个 <details> 就是一个选项卡,里面必须有 <summary> 作为点击的标题区域,剩下的部分就是点击后展开的内容面板。

<div class="grid">
  <details class="item" name="tabGroup" open>
    <summary>选项一</summary>
    <div>这里是第一个选项卡的内容,打开状态默认展示。</div>
  </details>
  <details class="item" name="tabGroup">
    <summary>选项二</summary>
    <div>点击第二个标题,第一个面板会收起,这个面板就展开了。</div>
  </details>
  <details class="item" name="tabGroup">
    <summary>选项三</summary>
    <div>第三个面板的内容,同样支持键盘操作。</div>
  </details>
</div>

这里有个关键点:给每个 <details> 设置相同的 name 属性值,比如这里都叫 "tabGroup"。这个操作就像给它们拉了个群,让它们在群里互斥——点开一个,同名的其他 <details> 会自动关上,这就模拟出了选项卡“同时只能开一个”的核心效果。open 属性标记了默认打开哪一个。整个结构不需要额外的 <div> 去模拟选项卡的标题栏,HTML语义上就是原生的折叠面板,这为后续的可访问性打下了基础。

布局,Grid打底Subgrid精装

CSS部分要干两件大事:一是让所有选项卡的内容面板共用一个展示区域,二是把 <summary> 标题栏排成一行,看起来像真的选项卡标签。

先给外层容器 .grid 设置网格。需要两行,第一行用来放标题栏,高度根据内容自动撑开;第二行用来放内容面板,占满剩余空间。列数就按选项卡数量来,这里三个选项卡,就分成三列。

.grid {
  display: grid;
  grid-template-rows: auto 1fr;
  grid-template-columns: repeat(3, 1fr);
  gap: 0 1rem;
}

接下来是关键,让每个 <details> 元素变成子网格(subgrid),继承父容器的行列结构。这样,每个 <details> 内部的 <summary> 和内容区域就能直接对齐到 .grid 的行列线上,不用再手动算尺寸。

.item {
  display: grid;
  grid-template-rows: subgrid;
  grid-template-columns: subgrid;
  grid-row: 1 / -1;
  grid-column: 1 / -1;
}

.itemgrid-rowgrid-column 都设成 1 / -1,是为了让每个选项卡都铺满整个 .grid 容器。这时候所有选项卡会完全重叠在一起,看着乱,但别急,这正是布局的基础。

内容区,统一位置只显示一个

所有选项卡面板内容应该都出现在第二行,并且横跨所有列。这里可以用 ::details-content 伪元素来精准控制内容区域的位置,避免在HTML里多包一层 <div>

.item::details-content {
  grid-row: 2;
  grid-column: 1 / -1;
  padding: 1rem;
  border-top: 2px solid #e2e8f0;
}

现在所有内容面板都挤在同一个位置了,但得让没打开的那些面板隐藏起来。利用 [open] 选择器,只显示当前打开的 <details> 的内容。

.item:not([open])::details-content {
  display: none;
}

这一步做完,界面清爽了,只会看到一个选项卡的内容。但标题栏还是堆叠在一起的,需要把它们按列分开。

标题栏,用变量分配合适的列

每个 <summary> 都应该位于网格的第一行,并且分别占据不同的列。可以用 :nth-of-type 选择器硬编码,但更好的办法是用CSS变量,这样以后增减选项卡数量时维护起来方便。

首先给每个 <summary> 统一设置 grid-row 为第一行,并设置一个变量 --n 来控制它占据哪一列。

summary {
  grid-row: 1;
  grid-column: var(--n) / span 1;
  padding: 0.75rem 1rem;
  cursor: pointer;
  border-bottom: 2px solid transparent;
}

.item[open] summary {
  border-bottom-color: #3b82f6;
  font-weight: 500;
}

然后在HTML里,通过内联样式给每个 <details> 设置不同的 --n 值。第一个选项卡的 --n 是1,第二个是2,第三个是3,以此类推。

<details class="item" name="tabGroup" open style="--n: 1">
  <summary>选项一</summary>
  <div>内容一</div>
</details>
<details class="item" name="tabGroup" style="--n: 2">
  <summary>选项二</summary>
  <div>内容二</div>
</details>
<details class="item" name="tabGroup" style="--n: 3">
  <summary>选项三</summary>
  <div>内容三</div>
</details>

这样一来,三个 <summary> 就分别坐到了网格的第一行第一列、第一行第二列、第一行第三列的位置,视觉上就成了并列的选项卡标签。

叠放顺序,让可点击区域露出来

因为每个 <details> 都铺满了整个 .grid,它们的 <summary> 虽然位置分开了,但元素本身的块级背景还是会相互覆盖,导致只能点到最后一个 <summary>。解决办法是给 <summary> 加上一个 z-index,让它们浮在顶层。

summary {
  position: relative;
  z-index: 1;
}

这一步做完,所有选项卡标签都能正常点击了,点哪个标签,对应的内容面板就显示出来,同时其他面板自动隐藏。

步骤操作要点作用
1给details加相同name属性实现互斥打开
2外层grid定义两行三列划分标题和内容区域
3details启用subgrid内部元素对齐父级网格线
4用::details-content固定内容区统一面板位置并控制显示
5summary通过变量分配列位置标签栏自动排开
6summary加z-index保证点击区域不被遮挡

如果手头项目里选项卡数量不固定,比如后台系统里不同模块的选项卡个数不一样,手动写内联样式变量确实有点呆。可以借助模板引擎来批量生成,比如在Liquid、Nunjucks或者PHP里循环输出,把循环的索引值赋值给 --n 变量。这样不管选项卡是三个还是五个,代码结构都是一致的,维护起来也省心。

这套方案用 <details> 自带的状态管理代替了JavaScript的点击切换逻辑,用CSS Grid的Subgrid特性解决了复杂的对齐问题。::details-content 虽然目前还不是所有浏览器都完全支持,但对于现代Web开发来说,已经是一个非常干净且值得尝试的选项卡实现方式。