构建标签页组件

构建类似于 iOS 和 Android 应用中标签页组件的基础概述。

在这篇文章中,我想分享关于为 Web 构建标签页组件的思考,该组件应具有响应性,支持多种设备输入,并在各种浏览器中工作。请尝试演示

演示

如果您更喜欢视频,这里是这篇文章的 YouTube 版本

概述

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

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

the collage is quite chaotic due to the huge diversity of styles the web has applied to the component concept
过去 10 年标签页组件 Web 设计风格的拼贴

Web 策略

总而言之,我发现这个组件非常容易构建,这要归功于一些关键的 Web 平台功能

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

HTML

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

其中有一些结构化内容成员:链接和 :target。我们需要一个链接列表,<nav> 非常适合,以及一个 <article> 元素列表,<section> 非常适合。每个链接哈希都将匹配一个 section,从而让浏览器通过锚定滚动内容。

点击链接按钮,滑动显示聚焦内容

例如,点击链接会自动在 Chrome 89 中聚焦 :target article,无需 JS。然后,用户可以像往常一样使用其输入设备滚动 article 内容。它是补充内容,如标记中所示。

我使用以下标记来组织标签页

<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>

接下来,我在 article 中填充了不同数量的 lorem ipsum 文本,并在链接中填充了混合长度和图像的标题集。有了要处理的内容,我们就可以开始布局了。

滚动布局

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

  • 导航 (粉色)是水平可滚动的
  • 内容区域 (蓝色)是水平可滚动的
  • 每个 article 项目 (绿色)是垂直可滚动的。
3 colorful boxes with color matching directional arrows which outline the scroll areas and show the direction they'll scroll.

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

  1. 窗口
    具有定义的尺寸且具有 overflow 属性样式的框。
  2. 超大表面
    在此布局中,它是列表容器:导航链接、section article 和 article 内容。

<snap-tabs> 布局

我选择的顶层布局是 flex(Flexbox)。我将方向设置为 column,因此标题和 section 是垂直排序的。这是我们的第一个滚动窗口,它使用 overflow hidden 隐藏所有内容。标题和 section 很快将采用 overscroll,作为单独的区域。

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

the header and section elements have hotpink overlays on them, outlining the space they take up in the component

标签页 <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 应与链接组水平移动,并且此标题布局有助于设置该阶段。这里没有绝对定位的元素!

the nav and span.indicator elements have hotpink overlays on them, outlining the space they take up in the component

接下来是滚动样式。事实证明,我们可以在 2 个水平滚动区域(标题和 section)之间共享滚动样式,因此我创建了一个实用程序类 .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 轴上的 overflow、滚动包含来捕获 overscroll、触摸设备上隐藏的滚动条,最后是 scroll-snap 来锁定内容呈现区域。我们的键盘标签页顺序是可访问的,任何交互都会自然地引导焦点。滚动捕捉容器还可以从其键盘获得不错的轮播样式交互。

标签页标题 <nav> 布局

导航链接需要以一行布局,没有换行符,垂直居中,并且每个链接项都应捕捉到 scroll-snap 容器。对于 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;
  }
}

每个链接都会设置自己的样式和尺寸,因此导航布局只需要指定方向和流。导航项上的唯一宽度使标签页之间的过渡变得有趣,因为指示器会将其宽度调整为新目标。根据此处元素的数量,浏览器将渲染滚动条或不渲染滚动条。

the a elements of the nav have hotpink overlays on them, outlining the space they take up in the component as well as where they overflow

标签页 <section> 布局

此 section 是一个 flex 项,需要成为空间的主要使用者。它还需要为要放入的 article 创建列。同样,对于 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 样式告诉网格始终以水平线布局子项,不换行,这正是我们想要的;溢出父窗口。

the article elements have hotpink overlays on them, outlining the space they take up in the component and where they overflow

我发现有时很难理解这些!此 section 元素适合放入一个框中,但也创建了一组框。我希望视觉效果和解释有所帮助。

标签页 <article> 布局

用户应该能够滚动 article 内容,并且只有在发生溢出时才应显示滚动条。这些 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;
}

我选择让 article 在其父滚动器内捕捉。我真的很喜欢导航链接项和 article 元素如何捕捉到其各自滚动容器的内联开始位置。它看起来和感觉起来都像是一种和谐的关系。

the article element and it's child elements have hotpink overlays on them, outlining the space they take up in the component and the direction they overflow

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

3 个滚动区域回顾

下面,我在系统设置中选择了“始终显示滚动条”。我认为对于布局来说,在启用此设置的情况下工作同样重要,因为这对我审查布局和滚动编排也很重要。

the 3 scrollbars are set to show, now consuming layout space, and our component still looks great

我认为在此组件中看到滚动条槽有助于清楚地显示滚动区域的位置、它们支持的方向以及它们如何相互交互。考虑一下这些滚动窗口框架中的每一个也是 flex 或 grid 布局的父元素。

DevTools 可以帮助我们可视化这一点

the scroll areas have grid and flexbox tool overlays, outlining the space they take up in the component and the direction they overflow
Chromium Devtools,显示了包含锚元素的 flexbox 导航元素布局、包含 article 元素的网格 section 布局以及包含段落和标题元素的 article 元素。

滚动布局已完成:捕捉、可深度链接和键盘可访问。为用户体验增强、样式和乐趣奠定了坚实的基础。

功能亮点

滚动捕捉的子元素在调整大小时保持其锁定位置。这意味着 JavaScript 无需在设备旋转或浏览器调整大小时将任何内容带入视图。通过选择除 Responsive 之外的任何模式,然后在 Chromium DevTools 设备模式中调整设备框架的大小来试用一下。请注意,元素保持在视图中并与其内容锁定。自从 Chromium 更新其实现以匹配规范以来,此功能一直可用。这是一篇关于它的博客文章

动画

此处动画工作的目标是清楚地将交互与 UI 反馈联系起来。这有助于引导或帮助用户顺利地发现所有内容。我将有目的地且有条件地添加运动效果。用户现在可以在其操作系统中指定他们的运动偏好,我非常喜欢在我的界面中响应他们的偏好。

我将链接标签页下划线和 article 滚动位置。捕捉不仅是漂亮的对齐方式,也是动画开始和结束的锚定。这使 <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 上的动画是针对全局时间帧刻度运行的,但有了内存中的自定义 sectionScrollTimeline,我可以改变这一切。

tabindicator.animate({
    transform: ...,
    width: ...,
  }, {
    duration: 1000,
    fill: 'both',
    timeline: sectionScrollTimeline,
  }
);

在深入了解动画的关键帧之前,我认为指出滚动的追随者 tabindicator 将根据自定义时间线(我们 section 的滚动)进行动画处理非常重要。这完成了链接,但缺少最后一个要素,即要在其间进行动画处理的状态点,也称为关键帧。

动态关键帧

有一种非常强大的纯声明式 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"]

为了总结策略,标签页指示器现在将根据 section 滚动器的滚动捕捉位置在 4 个关键帧之间进行动画处理。捕捉点在我们的关键帧之间创建了清晰的界限,并真正增加了动画的同步感。

active tab and inactive tab are shown with VisBug overlays which show passing contrast scores for both

用户通过交互驱动动画,看到指示器的宽度和位置从一个 section 更改为下一个 section,与滚动完美同步。

您可能没有注意到,但我对突出显示的导航项在被选中时颜色的过渡感到非常自豪。

当突出显示的项目具有更高的对比度时,未选中的较浅灰色显得更加靠后。过渡文本颜色很常见,例如在悬停和选中时,但在滚动时过渡该颜色,与下划线指示器同步,这更上一层楼。

这是我的做法

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 可用时,我们如何增强它。

深层链接更像是一个移动术语,但我认为深层链接的意图在这里通过标签页得到满足,因为您可以直接共享指向标签页内容的 URL。浏览器将在页面内导航到 URL 哈希中匹配的 ID。我发现此 onload 处理程序使效果跨平台。

window.onload = () => {
  if (location.hash) {
    tabsection.scrollLeft = document
      .querySelector(location.hash)
      .offsetLeft;
  }
}

滚动结束同步

我们的用户并非总是点击或使用键盘,有时他们只是自由滚动,他们应该能够这样做。当 section 滚动器停止滚动时,无论它停在哪里,都需要在顶部导航栏中匹配。

这是我等待滚动结束的方式:js tabsection.addEventListener('scroll', () => { clearTimeout(tabsection.scrollEndTimer); tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100); });

每当 section 滚动时,如果存在 section 超时,则清除该超时,并启动一个新的超时。当 section 停止滚动时,不要清除超时,并在静止 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 上构建的所有方法。创建一个 Glitch在 Twitter 上向我发送您的版本,我将把它添加到下面的社区混音部分。

社区混音