构建“标签页”组件

有关如何构建类似于 iOS 和 Android 应用中标签页的组件的基础概览。

在这篇博文中,我想分享一下如何针对 Web 构建自适应标签页组件,该组件支持多种设备输入,并且可以跨浏览器运行。试用演示版

演示

如果您更喜欢视频,请观看此帖子的 YouTube 版本:

概览

标签页是设计系统的常见组件,但可以有多种形状和形式。首先,我们推出了基于 <frame> 元素构建的桌面版标签页,现在,我们又推出了可根据物理属性为内容添加动画效果的流畅移动版组件。它们都试图实现同一目标:节省空间。

如今,标签页用户体验的基本要素就是一个按钮导航区域,该区域用于在显示框架中切换内容的可见性。许多不同的内容区域共用同一空间,但会根据导航栏中选择的按钮以条件方式显示。

由于 Web 对组件概念应用了极其多样化的样式,因此拼接图片非常混乱
过去 10 多年来标签页组件 Web 设计样式的拼图

网络策略

总而言之,得益于几个重要的 Web 平台功能,我发现这个组件的构建非常简单:

  • scroll-snap-points,通过适当的滚动停止位置实现优雅的滑动和键盘互动
  • 通过网址哈希实现深层链接,为浏览器处理页面内滚动锚定和共享支持
  • 使用 <a>id="#hash" 元素标记的屏幕阅读器支持
  • prefers-reduced-motion,用于启用交叉淡出转换和即时页内滚动
  • 草稿 @scroll-timeline Web 功能,用于动态添加下划线和颜色,以更改所选标签页

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>

我可以使用 hrefid 属性在 <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 ipsum 填充了文章,并为链接添加了不同长度的标题和图片集。有了要处理的内容,我们就可以开始排版了。

滚动布局

此组件中有 3 种不同类型的滚动区域:

  • 导航栏 (pink) 可水平滚动
  • 内容区域(蓝色)可水平滚动
  • 每篇报道项 (green) 均可垂直滚动。
3 个彩色方框,分别带有与颜色匹配的方向箭头,这些箭头勾勒出滚动区域并指示滚动的方向。

滚动涉及到两种不同类型的元素:

  1. 窗口
    一个具有已定义尺寸且具有 overflow 属性样式的框。
  2. 超大 surface
    在此布局中,是列表容器:导航链接、版块文章和文章内容。

<snap-tabs>”布局

我选择的顶级布局是 flex(Flexbox)。我将方向设置为 column,以便标题和部分按垂直方向排序。这是我们的第一个滚动窗口,它会使用 overflow hidden 隐藏所有内容。标题和版块很快就会作为单独的区域采用滚动回弹。

HTML
<snap-tabs>
  <header></header>
  <section></section>
</snap-tabs>
CSS
  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 against 
needing 100% */ flex-shrink: 0; /* fixes cross browser quarks */ min-block-size: fit-content; } }

我们再来看看彩色的 3 个滚动条图表:

  • 现在,<header> 已准备好成为(粉色)滚动容器。
  • <section> 已准备好成为(蓝色)滚动容器。

我在下面使用 VisBug 突出显示的帧可帮助我们了解滚动容器创建的窗口

标题和版块元素上有 Hotpink 叠加层,勾勒出它们在组件中占据的空间

标签页 <header> 布局

下一个布局几乎相同:我使用 flex 创建垂直排序。

HTML
<snap-tabs>
  <header>
    <nav></nav>
    <span class="snap-indicator"></span>
  </header>
  <section></section>
</snap-tabs>
CSS
header {
  display: flex;
  flex-direction: column;
}

.snap-indicator 应与链接组沿水平方向移动,此标题布局有助于设置该阶段。此处没有绝对定位元素!

nav 和 span.indicator 元素带有 Hotpink 叠加层,勾勒出它们在组件中占据的空间

接下来是滚动样式。事实证明,我们可以在两个水平滚动区域(标题和版块)之间共享滚动样式,因此我创建了一个实用程序类 .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 的快速工作!

HTML
<nav>
  <a></a>
  <a></a>
  <a></a>
  <a></a>
</nav>
CSS
  nav {
  display: flex;

  & a {
    scroll-snap-align: start;

    display: inline-flex;
    align-items: center;
    white-space: nowrap;
  }
}

每个链接都会自行设置样式和大小,因此导航栏布局只需指定方向和流程即可。导航项具有独特的宽度,当指示器根据新的目标调整宽度时,标签页之间的转换会变得有趣。浏览器是否会呈现滚动条,具体取决于此处的元素数量。

导航栏的 a 元素带有 Hotpink 叠加层,用于勾勒出它们在组件中占据的空间以及溢出的位置

标签页 <section> 布局

此部分是一个 flex 项,需要成为空间的主要占用者。它还需要创建要放置文章的列。再次祝您在 CSS 2021 大会上取得理想成效!block-size: 100% 会拉伸此元素,尽可能填满父元素,然后为自己的布局创建一系列宽度为父元素宽度的列。100%百分比在这里非常适用,因为我们对父级编写了强制约束条件。

HTML
<section>
  <article></article>
  <article></article>
  <article></article>
  <article></article>
</section>
CSS
  section {
  block-size: 100%;

  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
}

这就像是在说“尽可能以强制性方式垂直扩展”(请记住我们将标题设置为 flex-shrink: 0:它是对这种扩展推送的防范措施),这会为一系列全高列设置行高。auto-flow 样式指示网格始终将子项排列在水平线上,不换行,这正是我们想要的;让子项溢出父窗口。

文章元素上叠加了艳粉色,勾勒出它们在组件中占据的空间以及它们溢出的位置

有时我很难理解这些内容!此部分元素适合放入框中,但也创建了一组框。希望这些图片和说明对您有所帮助。

标签页 <article> 布局

用户应能够滚动文章内容,并且滚动条应仅在溢出时显示。这些文章元素的位置整齐有序。它们同时是滚动父项和滚动子项。浏览器在这里确实为我们处理了一些棘手的触摸、鼠标和键盘交互。

HTML
<article>
  <h2></h2>
  <p></p>
  <p></p>
  <h2></h2>
  <p></p>
  <p></p>
  ...
</article>
CSS
article {
  scroll-snap-align: start;

  overflow-y: auto;
  overscroll-behavior-y: contain;
}

我选择让文章在其父级滚动条中自动调整大小。我非常喜欢导航链接项和文章元素如何贴靠到各自滚动容器的 inline-start 位置。看起来和感觉上都像是和谐的关系。

article 元素及其子元素上有 Hotpink 叠加层,用于勾勒它们在组件中占据的空间以及溢出方向

文章是一个网格子项,其大小已预先确定为我们希望提供滚动用户体验的视口区域。这意味着,我在这里不需要任何高度或宽度样式,只需定义其溢出方式即可。我将 overflow-y 设置为 auto,然后还使用方便的 overscroll-behavior 属性捕获滚动互动。

3 个滚动区域回顾

在下方,我已在系统设置中选择“始终显示滚动条”。我认为,在开启此设置的情况下,布局正常运行非常重要,因为这有助于我检查布局和滚动协调。

3 个滚动条已设置为显示,现在占用了布局空间,但我们的组件仍然看起来很棒

我认为在此组件中看到滚动条边线有助于清晰地显示滚动区域的位置、支持的方向以及滚动区域之间的交互方式。考虑这些滚动窗口框架如何同时成为布局的 flex 或网格父级。

开发者工具可以帮助我们直观呈现这一点:

滚动区域带有网格和 flexbox 工具叠加层,用于显示它们在组件中占据的空间以及溢出方向
Chromium 开发者工具,显示了包含大量锚元素的 Flexbox 导航栏元素布局、包含大量文章元素的网格版块布局,以及包含大量段落和标题元素的文章元素。

滚动布局已完成:可贴靠、可深层链接且可通过键盘访问。为提升用户体验、美化界面和提供愉悦体验奠定坚实基础。

功能亮点

滚动固定的子项在调整大小期间会保持其锁定位置。这意味着,在设备旋转或浏览器调整大小时,JavaScript 无需将任何内容显示出来。如果想在 Chromium 开发者工具设备模式中试用此功能,可以选择除自适应以外的任何模式,然后调整设备框架的大小。请注意,该元素会保持在视野中,并与其内容一起锁定。自 Chromium 更新其实现以符合规范以来,此功能便可供使用。下面是介绍此功能的博文

动画

此处动画工作的目的是将互动与界面反馈明确联系起来。这有助于引导或协助用户(最好是)无缝发现所有内容。我会有目的地、有条件地添加动态效果用户现在可以在操作系统中指定动作偏好设置,而我非常乐意在界面中响应他们的偏好设置。

我要将标签下划线与报道滚动位置相关联。贴靠不仅是一种非常对齐的方式,还可以锚定动画的开始和结束。这样,<nav>(类似于迷你地图)便会与内容保持关联。我们将通过 CSS 和 JS 检查用户的动作偏好设置。您可以通过以下几种方式做到这一点!

滚动行为

您可以改进 :targetelement.scrollIntoView() 的动作行为。默认情况下,此值为“即时”。浏览器只是设置滚动位置。如果我们想转换到该滚动位置,而不是在该位置闪烁,该怎么办?

@media (prefers-reduced-motion: no-preference) {
  .scroll-snap-x {
    scroll-behavior: smooth;
  }
}

由于我们在这里引入了动作,而且是用户无法控制的动作(例如滚动),因此只有在用户在操作系统中没有关于减少动作的偏好设置时,我们才会应用此样式。这样,我们只向可以使用滚动动作的人引入滚动动作。

标签页指示符

此动画的目的是帮助将指示器与内容的状态相关联。我决定为偏好减少动作的用户提供色彩交叉淡化 border-bottom 样式,并为接受动作的用户提供滚动关联滑动 + 色彩淡化动画。

在 Chromium DevTools 中,我可以切换偏好设置,并演示 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
});

我希望 1 个内容跟随另一个内容的滚动位置,通过创建 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 个转换关键帧。宽度也是如此,系统会询问每个元素的动态宽度,然后将其用作关键帧值。

以下是根据我的字体和浏览器偏好设置生成的示例输出:

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 个关键帧进行动画处理,具体取决于分区滚动条的滚动卡顿位置。贴靠点可以清晰划分关键帧,真正增强动画的同步感觉。

活动标签页和非活动标签页会与 VisBug 叠加层一起显示,并同时显示两者的合格对比度得分

用户通过互动来驱动动画,看到指示器的宽度和位置从一个部分变为另一个部分,并完美跟踪滚动。

您可能没有注意到,但我非常自豪地向您展示突出显示的导航项变为选中项时的颜色转换效果。

当突出显示的项对比度更高时,未选中的浅灰色会显得更不明显。文本颜色的转换很常见,例如在光标悬停时和选中时转换颜色,但在下一个级别,则会在滚动时转换该颜色,与下划线指示器同步。

我是这样做的:

tabnavitems.forEach(navitem => {
  navitem.animate({
      color: [...tabnavitems].map(item =>
        item === navitem
          ? `var(--text-active-color)`
          : `var(--text-color)`)
    }, {
      duration: 1000,
      fill: 'both',
      timeline: sectionScrollTimeline,
    }
  );
});

每个标签页导航链接都需要这种新的彩色动画,其跟踪的滚动时间轴与下划线指示器相同。我使用与之前相同的时间轴:由于其作用是在滚动时发出一个刻度,因此我们可以在任何类型的动画中使用该刻度。就像之前一样,我在循环中创建了 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 可用时如何加以改进。

深层链接更像是一个移动术语,但我认为,通过标签页,您可以直接分享指向标签页内容的网址,从而实现深层链接的 intent。浏览器将在页面中导航到网址哈希中匹配的 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,如果找到了某个 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 可以声明式地管理用户体验。有时,他们会成为非常可爱的小情侣。

总结

现在您已经知道我是如何做到的,您会怎么做呢?这会带来一些有趣的组件架构!谁将在其喜爱的框架中制作第一个带有槽的版本?🙂

让我们多元化我们的方法,了解在 Web 上构建的所有方式。 创建一个 Glitch,将您的版本发推给我,然后我将其添加到下面的社区混剪部分。

社区混剪作品