构建“标签页”组件

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

在本文中,我想分享一下构建适用于 Web 的标签页组件的想法,该组件具有自适应性、支持多种设备输入,并且可在各种浏览器中使用。试用演示版

演示

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

概览

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

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

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

网站策略

总的来说,得益于一些关键的 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>

我可以使用 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 个彩色方框,带有颜色匹配的方向箭头,用于勾勒滚动区域并显示滚动方向。

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

  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 的 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 元素带有 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 样式会指示网格始终以水平线的形式排列子项,不换行,这正是我们想要的;即溢出父窗口。

文章元素上有 Hotpink 叠加层,用于勾勒出它们在组件中占据的空间以及溢出的位置

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

标签页 <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,然后在推特上向我发送您的版本,我会将其添加到下方的社区混剪部分。

社区混剪作品