如何让粘性元素和全屏元素协同工作

Avatar of Silvestar Bistrović
Silvestar Bistrović

DigitalOcean 为您旅程的每个阶段提供云产品。从 $200 免费积分 开始!

前几天我遇到了一个独特的需求:构建一个带有全屏元素的布局,同时一个元素始终粘附在顶部。最终发现要实现这一点相当棘手,因此我将其记录在此,以备不时之需。其中一部分难点在于处理小屏幕上的逻辑定位。

这个效果很难描述,所以我录制了我的屏幕来展示我的意思。特别注意主要号召性用语部分,即带有“立即尝试 Domino”标题的部分。

这个想法是在较大的视窗上将主要号召性用语显示在右侧,同时用户滚动经过其他部分。在较小的视窗上,号召性用语元素必须显示在带有“开始试用”标题的主要英雄部分之后。

这里有两个主要挑战

  • 制作不会干扰粘性元素的全屏元素
  • 避免重复 HTML

在我们深入研究几个可能的解决方案(及其局限性)之前,让我们先设置语义 HTML 结构。

HTML

在构建这种布局时,人们可能会倾向于构建重复的号召性用语部分:一个用于桌面版本,另一个用于移动版本,然后在适当的时候切换其可见性。这避免了需要在 HTML 中找到完美的位置,以及需要应用处理两种布局需求的 CSS。我必须承认,我偶尔也会犯这种错误。但这次,我想避免重复我的 HTML。

另一个需要考虑的是,我们在 .box--sticky 元素上使用了粘性定位,这意味着它需要是其他元素的兄弟元素,包括全屏元素,才能正常工作。

这是标记

<div class="grid">

  <div class="box box--hero">Hero Box</div>

  <div class="box box--sticky">Sticky Box</div>

  <div class="box box--bleed">Full-bleed Box</div>
  <div class="box box--bleed">Full-bleed Box</div>
  <!-- a bunch more of these -->

</div>

让我们粘起来

在 CSS 网格布局中制作粘性元素非常简单。我们在 .box--sticky 元素上添加 position: sticky,并使用 top: 0 偏移量,指示其开始粘附的位置。哦,请注意,我们只在视窗宽度大于 768px 时才使元素粘性。

@media screen and (min-width: 768px) {
  .box--sticky {
    position: sticky;
    top: 0;
  }
}

注意,在 Safari 中使用 overflow: auto 时,粘性定位存在已知问题。它在 caniuse 的已知问题部分中有记录

将 overflow 设置为 auto 的父元素将阻止 Safari 中的 position: sticky 工作。

很好,这很简单。接下来,让我们解决全屏元素的挑战。

解决方案 1:伪元素

第一个解决方案是我经常使用的方法:绝对定位的伪元素,从一侧延伸到另一侧。这里的技巧是使用负偏移量。

如果我们谈论的是居中内容,那么计算就非常简单

.box--bleed {
  max-width: 600px;
  margin-right: auto;
  margin-left: auto;
  padding: 20px;
  position: relative; 
}

.box--bleed::before {
  content: "";
  background-color: dodgerblue; 
  position: absolute;
  top: 0;
  bottom: 0;
  right: calc((100vw - 100%) / -2);
  left: calc((100vw - 100%) / -2);
}

简而言之,负偏移量是视窗宽度(100vw)减去元素宽度(100%),然后除以 -2,因为我们需要两个负偏移量。

注意,使用 100vw 时存在已知错误,该错误也在 caniuse 中有记录

目前除了 Firefox 之外,所有浏览器都错误地认为 100vw 是整个页面宽度,包括垂直滚动条,这会导致在设置 overflow: auto 时出现水平滚动条。

现在让我们在内容居中的情况下制作全屏元素。如果你再次观看视频,你会注意到粘性元素下方没有内容。我们不希望粘性元素覆盖内容,这就是为什么在这个特定布局中没有居中内容的原因。

首先,我们将创建网格

.grid {
  display: grid;
  grid-gap: var(--gap);
  grid-template-columns: var(--cols);
  max-width: var(--max-width);
  margin-left: auto;
  margin-right: auto;
}

我们使用自定义属性,使我们能够重新定义最大宽度、间隙和网格列,而无需重新声明属性。换句话说,我们不是重新声明 grid-gapgrid-template-columnsmax-width 属性,而是重新声明变量值

:root {
  --gap: 20px;
  --cols: 1fr;
  --max-width: calc(100% - 2 * var(--gap));
}

@media screen and (min-width: 768px) {
  :root {
    --max-width: 600px;
    --aside-width: 200px;
    --cols: 1fr var(--aside-width);
  }
}

@media screen and (min-width: 980px) {
  :root {
    --max-width: 900px;
    --aside-width: 300px;
  }
}

在视窗宽度为 768px 及以上的视窗上,我们定义了两列:一列具有固定宽度 --aside-width,另一列填充剩余空间 1fr,以及网格容器的最大宽度 --max-width

在视窗宽度小于 768px 的视窗上,我们定义了一列和间隙。网格容器的最大宽度是视窗宽度的 100%,减去两侧的间隙。

现在有趣的部分来了。在更大的视窗上,内容没有居中,因此计算不像你想象的那么简单。这就是它的样子

.box--bleed {
  position: relative;
  z-index: 0;
}

.box--bleed::before {
  content: "";
  display: block;
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc((100vw - (100% + var(--gap) + var(--aside-width))) / -2);
  right: calc(((100vw - (100% - var(--gap) + var(--aside-width))) / -2) - (var(--aside-width)));
  z-index: -1;
}

我们不是使用父元素宽度的 100%,而是考虑了间隙和粘性元素的宽度。这意味着全屏元素中内容的宽度不会超过英雄元素的边界。这样,我们确保粘性元素不会覆盖任何重要信息。

左侧偏移量比较简单,因为我们只需要从视窗宽度(100vw)中减去元素宽度(100%)、间隙(--gap)和粘性元素(--aside-width)。

left: (100vw - (100% + var(--gap) + var(--aside-width))) / -2);

右侧偏移量更复杂,因为我们必须将粘性元素的宽度添加到之前的计算结果中 --aside-width,以及间隙 --gap

right: ((100vw - (100% + var(--gap) + var(--aside-width))) / -2) - (var(--aside-width) + var(--gap));

现在我们确定粘性元素不会覆盖全屏元素中的任何内容。

这是具有水平错误的解决方案

这是具有水平错误修复的解决方案

修复方法是在主体元素的 x 轴上隐藏溢出,这通常也是一个好主意

body {
  max-width: 100%;
  overflow-x: hidden;
}

这是一个完全可行的解决方案,我们可以到此为止。但这样做有什么乐趣呢?通常有不止一种方法可以完成某件事,所以让我们看一下另一种方法。

解决方案 2:填充计算

我们不是使用居中的网格容器和伪元素,而是可以通过配置网格来实现相同的效果。让我们首先定义网格,就像我们上次做的那样

.grid {
  display: grid;
  grid-gap: var(--gap);
  grid-template-columns: var(--cols);
}

同样,我们使用自定义属性来定义间隙和模板列

:root {
  --gap: 20px;
  --gutter: 1px;
  --cols: var(--gutter) 1fr var(--gutter);
}

我们在视窗宽度小于 768px 的视窗上显示三列。中间列占用尽可能多的空间,而另外两列仅用于强制水平间隙。

@media screen and (max-width: 767px) {
  .box {
    grid-column: 2 / -2;
  }
}

请注意,所有网格元素都放置在中间列中。

在视窗宽度大于 768px 的视窗上,我们定义了一个 --max-width 变量,用于限制内部列的宽度。我们还定义了 --aside-width,即粘性元素的宽度。同样,通过这种方式,我们确保粘性元素不会被定位在全屏元素内部的任何内容之上。

:root {
  --gap: 20px;
}

@media screen and (min-width: 768px) {
  :root {
    --max-width: 600px;
    --aside-width: 200px;
    --gutter: calc((100% - (var(--max-width))) / 2 - var(--gap));
    --cols: var(--gutter) 1fr var(--aside-width) var(--gutter);
  }
}

@media screen and (min-width: 980px) {
  :root {
    --max-width: 900px;
    --aside-width: 300px;
  }
}

接下来,我们将计算间隙宽度。计算公式为

--gutter: calc((100% - (var(--max-width))) / 2 - var(--gap));

…其中 100% 是视窗宽度。首先,我们将内部列的最大宽度从视窗宽度中减去。然后,我们将该结果除以 2,以创建间隙。最后,我们将网格间隙减去以获得正确的间隙列宽度。

现在让我们将 .box--hero 元素推到网格的第一列

@media screen and (min-width: 768px) {
  .box--hero {
    grid-column-start: 2;
  }
}

这会自动将粘性框推到英雄元素之后。我们也可以显式定义粘性框的放置位置,如下所示

.box--sticky {
  grid-column: 3 / span 1;
}

最后,让我们通过将 grid-column 设置为 1 / -1 来制作全屏元素。这告诉元素从第一个网格项目开始内容,并延伸到最后一个网格项目。

@media screen and (min-width: 768px) {  
  .box--bleed {
    grid-column: 1 / -1;
  }
}

为了居中内容,我们将计算左右填充。左侧填充等于间隙列的大小,加上网格间隙。右侧填充等于左侧填充的大小,再加上另一个网格间隙以及粘性元素的宽度。

@media screen and (min-width: 768px) {
  .box--bleed {  
    padding-left: calc(var(--gutter) + var(--gap));
    padding-right: calc(var(--gutter) + var(--gap) + var(--gap) + var(--aside-width));
  }
}

这是最终解决方案

与第一个解决方案相比,我更喜欢这个解决方案,因为它没有使用有问题的视窗单位。


我喜欢 CSS 计算。使用数学运算并不总是很直观,尤其是在组合不同的单位(例如 100%)时。弄清楚 100% 的含义是努力的一半。

我还喜欢使用纯 CSS 来解决像这样简单但复杂的布局问题。现代 CSS 具有原生解决方案(如网格、粘性定位和计算),可以消除复杂且略显笨重的 JavaScript 解决方案。让我们把脏活累活留给浏览器吧!

您是否对这种方法有更好的解决方案或不同的方法?我很乐意听到您的想法。