关于如何构建类似于 iOS 和 Android 应用中的标签页组件的基础概览。
在这篇博文中,我想分享一下关于构建一个适用于 Web 的标签页组件的想法,该组件应具有自适应性、支持多种设备输入,并且可在各种浏览器中运行。试用演示版。
如果您更喜欢视频,可以观看此帖子的 YouTube 版本:
概览
标签页是设计系统的常见组件,但可以采用多种形状和形式。最初,我们基于 <frame>
元素构建了桌面版标签页,现在我们拥有了流畅的移动版组件,这些组件可以根据物理属性为内容添加动画效果。它们都试图实现同一目标:节省空间。
如今,标签页用户体验的基本要素是按钮导航区域,该区域可切换显示框架中内容的可见性。许多不同的内容区域共用同一空间,但会根据导航中选择的按钮有条件地呈现。

网络策略
总而言之,得益于一些关键的 Web 平台功能,我发现这个组件的构建非常简单:
scroll-snap-points
用于实现优雅的滑动和键盘互动,并提供适当的滚动停止位置- 通过网址哈希实现深层链接,以便在浏览器中处理网页内滚动锚定和分享支持
- 支持使用
<a>
和id="#hash"
元素标记的屏幕阅读器 prefers-reduced-motion
,用于启用淡入淡出转换和即时网页内滚动- 用于动态下划线和更改所选标签页颜色的
@scroll-timeline
网络功能(草稿)
HTML
从根本上讲,此处的用户体验是:点击链接,让网址表示嵌套的网页状态,然后随着浏览器滚动到匹配的元素,看到内容区域更新。
其中包含一些结构化内容成员:链接和 :target
。我们需要一个链接列表(<nav>
非常适合),以及一个 <article>
元素列表(<section>
非常适合)。每个链接哈希都会与一个部分匹配,从而让浏览器通过锚定滚动内容。
例如,在 Chrome 89 中,点击链接会自动聚焦 :target
文章,无需使用 JS。然后,用户可以像往常一样使用输入设备滚动文章内容。它是免费内容,如标记中所示。
我使用了以下标记来整理标签页:
<snap-tabs>
<header>
<nav>
<a></a>
<a></a>
<a></a>
<a></a>
</nav>
</header>
<section>
<article></article>
<article></article>
<article></article>
<article></article>
</section>
</snap-tabs>
我可以使用 href
和 id
属性在 <a>
和 <article>
元素之间建立连接,如下所示:
<snap-tabs>
<header>
<nav>
<a href="#responsive"></a>
<a href="#accessible"></a>
<a href="#overscroll"></a>
<a href="#more"></a>
</nav>
</header>
<section>
<article id="responsive"></article>
<article id="accessible"></article>
<article id="overscroll"></article>
<article id="more"></article>
</section>
</snap-tabs>
接下来,我用不同数量的 lorem 填充了文章,并用不同长度的链接和一组图片填充了标题。有了要处理的内容,我们就可以开始布局了。
滚动布局
此组件中有 3 种不同的滚动区域:
- 导航栏(粉色)可水平滚动
- 内容区域(蓝色)可水平滚动
- 每个文章项 (绿色) 都可以垂直滚动。

滚动涉及 2 种不同类型的元素:
- 窗口
具有已定义尺寸且具有overflow
属性样式的框。 - 过大的界面
在此布局中,它是列表容器:导航链接、版块文章和文章内容。
“<snap-tabs>
”布局
我选择的顶级布局是 flex (Flexbox)。我将方向设置为 column
,因此标题和部分按垂直顺序排列。这是我们的第一个滚动窗口,它会隐藏所有溢出内容。标题和部分很快将采用过滚动作为单独的区域。
<snap-tabs> <header></header> <section></section> </snap-tabs>
snap-tabs { display: flex; flex-direction: column; /* establish primary containing box */ overflow: hidden; position: relative; & > section { /* be pushy about consuming all space */ block-size: 100%; } & > header { /* defend againstneeding 100% */ flex-shrink: 0; /* fixes cross browser quarks */ min-block-size: fit-content; } }
让我们回到那个色彩鲜艳的 3 滚动图:
<header>
现在已准备好成为(粉色)滚动容器。<section>
已准备好成为 (蓝色)滚动容器。
我在下面用 VisBug 突出显示的框架有助于我们了解滚动容器创建的窗口。

“标签页 <header>
”布局
下一个布局几乎相同:我使用 flex 来创建垂直顺序。
<snap-tabs> <header> <nav></nav> <span class="snap-indicator"></span> </header> <section></section> </snap-tabs>
header { display: flex; flex-direction: column; }
.snap-indicator
应与链接组一起水平移动,而此标题布局有助于实现这一点。此处没有绝对定位的元素!

接下来,我们来看看滚动样式。事实证明,我们可以在两个水平滚动区域(标题和部分)之间共享滚动样式,因此我创建了一个实用程序类 .scroll-snap-x
。
.scroll-snap-x {
/* browser decide if x is ok to scroll and show bars on, y hidden */
overflow: auto hidden;
/* prevent scroll chaining on x scroll */
overscroll-behavior-x: contain;
/* scrolling should snap children on x */
scroll-snap-type: x mandatory;
@media (hover: none) {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
}
每个都需要 x 轴上的溢出、用于捕获过滚的滚动包含、用于触控设备的隐藏滚动条,以及用于锁定内容呈现区域的滚动捕捉。我们的键盘标签页顺序易于访问,任何互动都会自然地引导焦点。滚动贴靠容器还可以通过键盘获得出色的轮播界面样式互动。
标签页标题 <nav>
布局
导航链接需要在一行中布局,不能换行,垂直居中,并且每个链接项都应贴靠到滚动贴靠容器。2021 年 CSS 的 Swift 工作!
<nav> <a></a> <a></a> <a></a> <a></a> </nav>
nav { display: flex; & a { scroll-snap-align: start; display: inline-flex; align-items: center; white-space: nowrap; } }
每个链接都会自行设置样式和大小,因此导航布局只需指定方向和流。导航项上的唯一宽度使标签页之间的过渡变得有趣,因为指示器会将其宽度调整为新的目标宽度。浏览器会根据此处包含的元素数量来决定是否呈现滚动条。

“标签页 <section>
”布局
此部分是弹性项,需要成为空间的主要使用者。它还需要创建用于放置文章的列。再次感谢您为 CSS 2021 提供的快速服务!block-size: 100%
会拉伸此元素以尽可能填充父元素,然后针对其自身的布局,它会创建一系列宽度为父元素 100%
的列。由于我们已对父级设置了严格的约束条件,因此百分比在这里非常适用。
<section> <article></article> <article></article> <article></article> <article></article> </section>
section { block-size: 100%; display: grid; grid-auto-flow: column; grid-auto-columns: 100%; }
这就像我们在说“尽可能以强推的方式垂直展开”(请记住我们设置为 flex-shrink: 0
的标题:这是针对这种展开强推的防御措施),这会为一组全高列设置行高。auto-flow
样式会告知网格始终以水平行的方式布局子元素,不换行,这正是我们想要的;溢出父窗口。

我有时会觉得这些概念很难理解!此部分元素适合放入一个盒子中,但同时也创建了一组盒子。希望这些图片和说明对您有所帮助。
“标签页 <article>
”布局
用户应能滚动浏览文章内容,并且只有在内容溢出时才显示滚动条。这些文章元素的位置整齐。它们同时是滚动父项和滚动子项。浏览器实际上在这里为我们处理了一些棘手的触控、鼠标和键盘交互。
<article> <h2></h2> <p></p> <p></p> <h2></h2> <p></p> <p></p> ... </article>
article { scroll-snap-align: start; overflow-y: auto; overscroll-behavior-y: contain; }
我选择让文章在其父滚动容器中贴靠。我非常喜欢导航链接项和文章元素贴靠到各自滚动容器的内联起始位置的方式。看起来和感觉起来都像是一种和谐的关系。

文章是网格子项,其大小预先确定为我们想要提供滚动用户体验的视口区域。这意味着我不需要在此处设置任何高度或宽度样式,只需定义其溢出方式即可。我将 overflow-y 设置为 auto,然后还使用方便的 overscroll-behavior 属性捕获滚动互动。
3 个滚动区域总结
在下图中,我已在系统设置中选择“始终显示滚动条”。我认为,对于布局来说,在此设置开启的情况下正常运行非常重要,因为这样我才能查看布局和滚动编排。

我认为,在此组件中显示滚动条边衬区有助于清晰地展示滚动区域的位置、支持的方向以及它们之间的互动方式。请注意,每个滚动窗口框架也是布局的 flex 或网格父级。
开发者工具可以帮助我们直观呈现这一点:

滚动布局已完成:可贴靠、可深层链接和可通过键盘访问。为用户体验增强功能、样式和惊喜奠定坚实基础。
功能聚焦
在调整大小时,贴靠滚动子项会保持其锁定位置。这意味着,在设备旋转或浏览器调整大小时,JavaScript 无需将任何内容显示在视图中。在 Chromium 开发者工具的设备模式中试用此功能,方法是选择除自适应以外的任何模式,然后调整设备框架的大小。 请注意,元素会保持在视图中,并与其内容锁定。自 Chromium 更新其实现以符合规范以来,此功能一直可用。您可以参阅这篇博文了解相关信息。
动画
此处的动画工作的目标是将互动与界面反馈清晰地联系起来。这有助于引导或协助用户顺利发现所有内容。我将有目的地添加运动,并根据条件添加运动。用户现在可以在操作系统中指定运动偏好设置,而我非常乐意在界面中响应用户的偏好设置。
我将把标签页下划线与文章滚动位置相关联。贴靠不仅是漂亮的对齐方式,也是锚定动画的开始和结束位置。这样一来,充当迷你地图的 <nav>
就会与内容保持关联。我们将从 CSS 和 JS 中检查用户的运动偏好设置。有几个很棒的地方可以考虑!
滚动行为
有机会增强 :target
和 element.scrollIntoView()
的运动行为。默认情况下,它是即时的。浏览器只会设置滚动位置。如果我们想过渡到该滚动位置,而不是闪烁到该位置,该怎么办?
@media (prefers-reduced-motion: no-preference) {
.scroll-snap-x {
scroll-behavior: smooth;
}
}
由于我们在此处引入了运动效果,并且是用户无法控制的运动效果(例如滚动),因此只有当用户在其操作系统中没有关于减少运动效果的偏好设置时,我们才会应用此样式。这样一来,我们只会向接受这种滚动效果的用户引入滚动动画。
标签页指示器
此动画旨在帮助用户将指示器与内容状态相关联。我决定为偏好减少动作的用户提供色彩淡入淡出 border-bottom
样式,并为可以接受动作的用户提供与滚动关联的滑动 + 色彩淡入淡出动画。
在 Chromium 开发者工具中,我可以切换偏好设置并演示 2 种不同的过渡样式。我在构建此应用时获得了许多乐趣。
@media (prefers-reduced-motion: reduce) {
snap-tabs > header a {
border-block-end: var(--indicator-size) solid hsl(var(--accent) / 0%);
transition: color .7s ease, border-color .5s ease;
&:is(:target,:active,[active]) {
color: var(--text-active-color);
border-block-end-color: hsl(var(--accent));
}
}
snap-tabs .snap-indicator {
visibility: hidden;
}
}
当用户偏好减少动作时,我会隐藏 .snap-indicator
,因为我不再需要它了。然后,我将其替换为 border-block-end
样式和 transition
。另请注意,在标签页互动中,不仅活动导航项具有品牌下划线突出显示效果,而且其文字颜色也更深。有效元素的文字颜色对比度更高,并且具有明亮的底光效果。
只需添加几行额外的 CSS,就能让用户感受到我们贴心地尊重了他们的动画偏好设置。我太喜欢了。
@scroll-timeline
在上一部分中,我向您展示了如何处理减少运动的淡入淡出样式,而在本部分中,我将向您展示如何将指示器和滚动区域关联在一起。接下来是一些有趣的实验性内容。希望您也和我一样激动。
const { matches:motionOK } = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
我首先通过 JavaScript 检查用户的运动偏好设置。如果此结果为 false
,表示用户偏好减少动态效果,那么我们将不会运行任何滚动链接动态效果。
if (motionOK) {
// motion based animation code
}
在撰写本文时,浏览器对 @scroll-timeline
的支持情况为“无”。它是一份草稿规范,只有实验性实现。不过,它有一个填充区,我会在这个演示中使用它。
ScrollTimeline
虽然 CSS 和 JavaScript 都可以创建滚动时间轴,但我选择了 JavaScript,这样我就可以在动画中使用实时元素测量。
const sectionScrollTimeline = new ScrollTimeline({
scrollSource: tabsection, // snap-tabs > section
orientation: 'inline', // scroll in the direction letters flow
fill: 'both', // bi-directional linking
});
我希望一个元素跟随另一个元素的滚动位置,因此通过创建 ScrollTimeline
,我定义了滚动链接的驱动程序,即 scrollSource
。
通常,网页上的动画会根据全局时间框架计时周期运行,但如果内存中有自定义 sectionScrollTimeline
,我可以更改所有这些。
tabindicator.animate({
transform: ...,
width: ...,
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
在介绍动画的关键帧之前,我认为有必要指出,滚动跟随者 tabindicator
将根据自定义时间轴(即相应部分的滚动)进行动画处理。这样就完成了关联,但还缺少最后一个要素,即用于在动画中切换的状态点,也称为关键帧。
动态关键帧
有一种非常强大的纯声明式 CSS 方式可以实现 @scroll-timeline
动画,但我选择实现的动画过于动态。无法在 auto
宽度之间进行过渡,也无法根据子元素的长度动态创建关键帧数量。
不过,JavaScript 知道如何获取该信息,因此我们将自行迭代子级,并在运行时获取计算值:
tabindicator.animate({
transform: [...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`),
width: [...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
对于每个 tabnavitem
,解构 offsetLeft
位置并返回一个使用该位置作为 translateX
值的字符串。这会为动画创建 4 个 transform 关键帧。宽度也是如此,每个宽度都会询问其动态宽度,然后将其用作关键帧值。
以下是根据我的字体和浏览器偏好设置生成的输出示例:
TranslateX 关键帧:
[...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`)
// results in 4 array items, which represent 4 keyframe states
// ["translateX(0px)", "translateX(121px)", "translateX(238px)", "translateX(464px)"]
宽度关键帧:
[...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
// results in 4 array items, which represent 4 keyframe states
// ["121px", "117px", "226px", "67px"]
总结一下该策略,标签页指示器现在将根据部分滚动器的滚动捕捉位置在 4 个关键帧之间进行动画处理。贴靠点可清晰区分关键帧,并增强动画的同步感。

用户通过互动来驱动动画,看到指示器的宽度和位置从一个部分变化到下一个部分,与滚动完美同步。
您可能没有注意到,但我非常自豪的是,当突出显示的导航项变为选中状态时,颜色会发生过渡。
当突出显示的项目具有更高的对比度时,未选中的浅灰色会显得更加靠后。通常,我们会为文本设置颜色过渡效果,例如在悬停和选中时,但如果能在滚动时实现颜色过渡,并与下划线指示器同步,那就更上一层楼了。
以下是我所采取的方法:
tabnavitems.forEach(navitem => {
navitem.animate({
color: [...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
});
每个标签页导航链接都需要这种新的颜色动画,并跟踪与下划线指示器相同的滚动时间轴。我使用与之前相同的时间轴:由于其作用是在滚动时发出 tick,因此我们可以在任何类型的动画中使用该 tick。与之前一样,我在循环中创建了 4 个关键帧,并返回颜色。
[...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
// results in 4 array items, which represent 4 keyframe states
// [
"var(--text-active-color)",
"var(--text-color)",
"var(--text-color)",
"var(--text-color)",
]
颜色为 var(--text-active-color)
的关键帧会突出显示链接,否则为标准文本颜色。由于存在嵌套循环,因此实现起来相对简单,因为外层循环是每个导航项,而内层循环是每个导航项的个人关键帧。我检查外部循环元素是否与内部循环元素相同,并使用该信息来了解何时选中了元素。
我在撰写本文时非常开心。喜欢得不得了
更多 JavaScript 增强功能
需要提醒的是,我在此处展示的核心内容无需 JavaScript 即可运行。不过,我们来看看如何在 JavaScript 可用时增强它。
深层链接
深层链接更像是一个移动术语,但我认为,通过标签页,您可以直接分享网址以指向标签页的内容,从而实现深层链接的意图。浏览器将在页面内导航到网址哈希中匹配的 ID。我发现此 onload
处理程序可在各个平台实现相应效果。
window.onload = () => {
if (location.hash) {
tabsection.scrollLeft = document
.querySelector(location.hash)
.offsetLeft;
}
}
滚动结束同步
我们的用户并不总是点击或使用键盘,有时他们只是随意滚动,这是他们应该能够做到的。当部分滚动条停止滚动时,无论它停在哪个位置,都需要在顶部导航栏中进行匹配。
以下是我等待滚动结束的方式:
js
tabsection.addEventListener('scroll', () => {
clearTimeout(tabsection.scrollEndTimer);
tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100);
});
每当滚动部分时,清除部分超时(如果有),并开始新的超时。当部分停止滚动时,不清除超时,并在静止 100 毫秒后触发。触发时,调用用于确定用户停止位置的函数。
const determineActiveTabSection = () => {
const i = tabsection.scrollLeft / tabsection.clientWidth;
const matchingNavItem = tabnavitems[i];
matchingNavItem && setActiveTab(matchingNavItem);
};
假设滚动已贴靠,将当前滚动位置除以滚动区域的宽度应得到一个整数,而不是小数。然后,我尝试通过此计算出的索引从缓存中获取 navitem,如果找到匹配项,则发送该匹配项以将其设置为有效。
const setActiveTab = tabbtn => {
tabnav
.querySelector(':scope a[active]')
.removeAttribute('active');
tabbtn.setAttribute('active', '');
tabbtn.scrollIntoView();
};
设置有效标签页首先要清除当前所有有效标签页,然后为传入的导航项提供有效状态属性。对 scrollIntoView()
的调用与 CSS 之间存在值得注意的有趣互动。
.scroll-snap-x {
overflow: auto hidden;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
}
在横向滚动贴靠实用程序 CSS 中,我们嵌套了一个媒体查询,如果用户对动态效果的容忍度较高,则应用 smooth
滚动。JavaScript 可以自由调用以将滚动元素显示在视图中,而 CSS 可以声明式地管理用户体验。有时,它们会搭配出令人愉悦的小惊喜。
总结
现在您已经知道我是如何做到的,那么您会怎么做呢?这使得组件架构非常有趣!谁将率先在自己喜欢的框架中制作出带有 slot 的第一个版本?🙂
让我们丰富方法,了解在网络上构建内容的所有方式。 创建 Glitch,在 Twitter 上私信我你的版本,我会将其添加到下方的社区混音部分。
社区混音作品
- @devnook、@rob_dodson 和 @DasSurma 撰写的有关 Web 组件的文章。
- @jhvanderschee 带有按钮:Codepen。