前几天我遇到了一个独特的需求:构建一个带有全屏元素的布局,同时一个元素始终粘附在顶部。最终发现要实现这一点相当棘手,因此我将其记录在此,以备不时之需。其中一部分难点在于处理小屏幕上的逻辑定位。
这个效果很难描述,所以我录制了我的屏幕来展示我的意思。特别注意主要号召性用语部分,即带有“立即尝试 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-gap
、grid-template-columns
和 max-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 解决方案。让我们把脏活累活留给浏览器吧!
您是否对这种方法有更好的解决方案或不同的方法?我很乐意听到您的想法。
我不敢相信我要这么说,但有时使用 JavaScript 更合理。
我懂你的意思,但这样有什么意思呢?:D
我把这个挑战带到 Webflow 里,看看我能想出什么:https://sticky-fullwidth.webflow.io/
不错!我还没用过 Webflow。
顺便说一下,我的粘性元素在移动设备上没有粘性,但它也可以在移动设备上作为粘性元素使用。
CTA 块不是粘性的,它要么在流中定位,要么在静态空间中定位。我很好奇为什么 position: absolute 不能解决这个问题。
感谢你的工作。这些信息真的很有用。现在我将在实践中尝试一下。