基础性地概述了如何构建类似于 iOS 和 Android 应用中的标签页组件。
在本文中,我想分享一下关于为 Web 构建标签页组件的想法,该组件具有自适应性,支持多种设备输入,并且可在各种浏览器中运行。 试用演示版。
如果您更喜欢视频,可以观看此博文的 YouTube 版本:
概览
标签页是设计体系的常见组件,但可以采用多种形状和形式。首先是基于 <frame> 元素构建的桌面标签页,现在我们有了流畅的移动组件,可以根据物理属性为内容添加动画效果。
它们都试图做同一件事:节省空间。
如今,标签页用户体验的基本要素是一个按钮导航区域,用于切换显示框架中内容的可见性。许多不同的内容区域共享同一空间,但会根据导航栏中选择的按钮有条件地呈现。
网站策略
总而言之,我发现这个组件非常简单,这要归功于一些关键的 Web 平台功能:
scroll-snap-points,可实现优雅的滑动和键盘互动,并提供适当的滚动停止位置- 通过网址哈希深层链接到浏览器处理的页内滚动锚定和分享支持
- 支持屏幕阅读器,并提供
<a>和id="#hash"元素标记 prefers-reduced-motion,用于启用淡入淡出过渡和即时页内滚动- 草案中的
@scroll-timelineWeb 功能,用于动态为所选标签页添加下划线和更改颜色
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 种不同类型的滚动区域:
- 导航栏(粉色)可水平滚动
- 内容区域(蓝色)可水平滚动
- 每个文章项(绿色)均可垂直滚动。
滚动涉及两种不同类型的元素:
- 窗口
具有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 轴上溢出、滚动包含以捕获滚动回弹、隐藏触摸设备的滚动条,最后需要滚动贴靠来锁定内容呈现区域。我们的键盘 Tab 键顺序是无障碍的,任何互动都会自然地引导焦点。滚动贴靠容器还可通过键盘 实现出色的轮播界面样式互动
标签页标题 <nav> 布局
导航链接需要排成一行,不换行,垂直居中,并且每个链接项都应贴靠到滚动贴靠容器。Swift work for 2021 CSS!
<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> 布局
此部分是一个弹性项,需要占据主要空间。它还需要创建用于放置文章的列。再次感谢您在 2021 年 CSS 期间的高效工作!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 样式会指示网格始终在水平线上布置子元素,不换行,这正是我们想要的效果:溢出父窗口。
我发现这些内容有时很难理解!此 section 元素适合一个框,但也创建了一组框。希望这些图示和说明对您有所帮助。
标签页 <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 或 Grid 父级。
开发者工具可以帮助我们直观地看到这一点:
滚动布局已完成:可贴靠、可深层链接,并且可通过键盘访问。为用户体验提升、风格和愉悦感奠定坚实基础。
功能聚焦
在调整大小时,滚动贴靠的子项会保持其锁定位置。这意味着,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 的支持为无。它是一个规范草案,仅有实验性实现。不过,它有一个 polyfill,我在此演示中使用了它。
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。通常,Web 上的动画会根据全局时间框架 tick 运行,但通过内存中的自定义 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 个转换关键帧。宽度也是如此,系统会询问每个元素的动态宽度,然后将其用作关键帧值。
以下是根据我的字体和浏览器偏好设置生成的示例输出:
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 即可运行。话虽如此,我们来看看如何在 JS 可用时增强它。
深层链接
深层链接更多是一个移动端术语,但我认为,标签页在这里实现了深层链接的意图,因为您可以直接分享指向标签页内容的网址。浏览器将在页面内导航到网址哈希中匹配的 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);
};
假设滚动贴靠,用当前滚动位置除以滚动区域的宽度,结果应为整数,而不是小数。然后,我尝试通过此计算出的索引从缓存中获取导航项,如果找到匹配项,则将其设置为活动状态。
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 可以以声明方式管理用户体验。
有时,它们会形成非常有趣的搭配。
总结
现在,你已经知道我是怎么做的了,你会怎么做呢?!这会带来一些有趣的组件架构!谁会率先在自己喜欢的框架中制作出带插槽的版本?🙂
让我们采用多样化的方法,学习在 Web 上构建应用的所有方式。 如果您创建了演示,请在 Twitter 上向我发送您的版本,我会将其添加到以下社区混剪部分。
社区混剪
- @devnook、@rob_dodson 和 @DasSurma 撰写的 Web 组件文章。
- @jhvanderschee,带按钮:Codepen。