构建“标签页”组件

基本概述如何构建与 iOS 和 Android 应用中的标签页组件类似的标签页组件。

在这篇博文中,我想分享一个关于为 Web 构建自适应、支持多种设备输入且可跨浏览器运行的标签页组件的想法。试用演示

演示

如果你更喜欢视频,可以参考本博文的 YouTube 版本:

概览

标签页是设计系统的常见组件,但可以采用许多形状和形式。最初是基于 <frame> 元素构建的桌面标签页,现在我们有了基于物理属性为内容添加动画效果的繁琐移动组件。它们同样都是为了节省空间。

如今,标签页用户体验的关键在于按钮导航区域,该区域可切换内容在显示框架中的可见性。许多不同的内容区域共用相同的空间,但会根据导航栏中选择的按钮有条件地呈现。

因为网络应用到组件概念的样式丰富多样,所以拼贴非常混乱
过去 10 年间标签页组件网页设计样式的拼贴

网络策略

总的来说,我发现这个组件构建起来非常简单,这要归功于几项重要的 Web 平台功能:

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

HTML

从根本上说,这里的用户体验是:点击一个链接,让网址代表嵌套页面状态,然后在浏览器滚动到匹配元素时看到内容区域更新。

其中包含一些结构性内容成员:链接和 :target。我们需要一个 <nav> 适合的链接列表,以及一个 <section> 适合的 <article> 元素列表。每个链接哈希都会与一个部分匹配,这样浏览器就可以通过锚定来滚动浏览内容。

已点击链接按钮,在聚焦的内容中滑动

例如,点击链接会自动聚焦于 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,链接也添加了长度和图片长度不一的标题集。有了内容后,我们就可以开始布局了。

滚动布局

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

  • 导航栏(粉色)可水平滚动
  • 内容区域(蓝色)可以水平滚动
  • 每个文章项(绿色)都可以垂直滚动。
3 个彩色方框,其中各有颜色匹配的方向箭头,这些箭头勾勒出滚动区域并显示滚动方向。

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

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

<snap-tabs>”布局

我选择的顶层布局是 flex (Flexbox)。我将方向设置为 column,使标题和部分垂直排列。这是我们的第一个滚动窗口,它会通过隐藏溢出菜单隐藏所有内容。标题和部分将很快采用滚动回弹,作为单独的可用区。

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 突出显示的帧可帮助我们查看滚动容器创建的窗口

标题和部分元素上都有艳粉色叠加层,勾勒出它们在组件中占用的空间

标签页 <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 元素上都有艳粉色叠加层,勾勒出它们在组件中占用的空间

接下来是滚动样式。事实证明,我们可以在 2 个水平滚动区域(标题和部分)之间共享滚动样式,因此我创建了一个实用程序类 .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> 布局

导航链接需要排成一行,不换行,垂直居中,并且每个链接项都应与滚动贴靠容器贴靠。2021 年 CSS 助力 Swift 做工作!

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 元素上有热粉色叠加层,概述了它们在组件中占据的空间以及溢出位置

标签页 <section> 布局

此部分属于弹性内容,因此占据主导地位。它还需要创建要放入报道的列。再次强调一下 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;
}

我选择了让文章贴靠在父级滚动条内。我非常喜欢导航链接项和文章元素如何与其各自滚动容器的内嵌起始位置对应起来。这看起来、感觉像是和谐的关系

文章元素及其子元素上都有艳粉色叠加层,其中勾勒出它们在组件中占据的空间以及溢出方向

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

3 个滚动区域要点回顾

在下方,我在系统设置中选择了“始终显示滚动条”。我认为,启用此设置对布局来说非常重要,因为我可以查看布局和滚动编排。

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

我认为,查看此组件中的滚动条边线有助于清楚地显示滚动区域的位置、滚动区域支持的方向以及它们之间的交互方式。想一想,其中每个滚动窗口框架如何也是布局的弹性或网格父级。

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

滚动区域具有网格和 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。通常,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 个关键帧之间以动画形式呈现。贴靠点可以使关键帧之间出现清晰的轮廓线,从而真正增添动画的同步感。

活跃标签页和非活跃标签页会同时显示 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,
    }
  );
});

每个标签页导航链接都需要这种新的颜色动画,其跟踪与下划线指示器相同的滚动时间轴。我使用与之前相同的时间轴:由于它的作用是在滚动时发出 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) 的关键帧会突出显示链接,它的颜色不是标准文本颜色。其中的嵌套循环使其相对简单,因为外循环是每个导航项,内循环是每个 navitem 的个人关键帧。我会检查外部循环元素是否与内循环元素相同 并据此了解何时被选中

我在写这本书时享受到了很多乐趣。喜欢得不得了

更多的 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);
};

假设滚动已贴靠,如果将当前滚动位置与滚动区域的宽度相除,则会得到整数而非小数。然后,我尝试通过这个计算出的索引从缓存中获取 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,将您的版本发推给我,然后我就会将其添加到下面的社区混剪部分。

社区混剪作品