CSS倒影那些事儿

发布于 静子

最近我在Codepen看到一个这样子的加载示例。使用CSS完成的具有渐变褪色倒影的、旋转的3D条形块。每一个条形块使用了一个元素,之后进行复制,这些元素形成倒影,最后添加渐变进行覆盖形成渐变褪色效果。这听起来有点像用你的左脚趾从背后抓右耳朵般不切实际。更不用说渐变覆盖方法形成褪色效果在非平面背景色不起作用了。是不是有更好的方法使用CSS可以实现这种效果呢?

CSS倒影那些事儿

回答无非是:“肯定”和“否定”。“肯定”是可以实现,以及“否定”是不可以实现。不幸的是,尽管该代码可以使用预处理器进行压缩(外界获取的一个循环中生成的代码会大大减少),如果想要跨浏览器获取最佳的效果,为了倒影复制所有的条形块以及使用渐变覆盖生成褪色效果的方法仍然是最好的,而不是使用canvas。

这篇文章主要探索创建倒影的方法,说明“大部分”的解决方案以及跨浏览器产生的令人头疼的问题,最后讨论我自己的想法。

案例的基本设置

在创建倒影之前,让我们看看如何在大部分的浏览器中创建位置以及条形块的阴影。

创建条形块

首先,创建一个包裹.loader元素,在内部设置10.bar元素。

<div class='loader'>
  <div class='bar'></div>
  <!-- repeat to create 9 more bars -->
</div>

书写多行同样的代码是一件令人头痛的事情,这种情况下我们会使用一种预处理器。这里使用了Haml,当然你可以使用其它的。

.loader
  - 10.times do
  .bar

将所有的元素从视口的中间进行绝对定位。在大多数的情况下,设置top:50%,但是这里,为了之后的方便起见设置bottom:50%

div {
  position: absolute;
  bottom: 50%; left: 50%;
}

决定条形块的width以及height,这里方便看见它们,设置一个background:

$bar-w: 1.25em;
$bar-h: 5 * $bar-w;

.bar {
  width: $bar-w; height: $bar-h;
  background: currentColor;
}

我们想要这些条形块的底部边缘和视口的中线(将视口分为相等的两部分)相吻合,并且我们已经设置了bottom:50

现在,所有的条形块已经一个接一个堆叠在一起,其左边缘正对将视口均分两份(左侧以及右侧)的垂直线,其底部边缘在将视口均分为两份(上部以及下部)的水平线上。

定位条形块

现在需要对他们进行定位,使得第一个条形块的左边缘以及最后一个条形块的右边缘位于视口的水平中间位置。这个距离总是条形块数量的一半($n)乘以条形块的width($bar-w)。最初的演示使用的是vanilla CSS,但是这里为了减少代码的数量使用了Sass。

意味着所有的条形块从现在的位置开始,我们需要左移.5 * $n * $bar-w。左侧是x轴的负方向,意味着需要在前面添加一个负号(-)。margin-left的值为-.5 * $n * $bar-w

第二个条形块(若索引基于0,这里为1)向右(x轴的正方向)是一个宽度。margin-left的值为-.5 * $n * $bar-w + $bar-w

第三个条形块(若索引基于0,这里为2)向右(x轴的正方向)是一个宽度。margin-left的值为-.5 * $n * $bar-w + 2*$bar-w

最后一个条形块(若索引基于0,这里为$n - 1)向右(x轴的正方向)是一个宽度。margin-left的值为-.5 * $n * $bar-w + ($n - 1) * $bar-w

一般情况下,如果我们考虑到当前的条形块$i是基于0的,margin-left的值为-.5 * $n * $bar-w + $i * $bar-w,简写为($i - .5 * $n) * $bar-w

这里可以使用一个Sass循环进行条形块的定位:

$n: 10;

@for $i from 0 to $n {
  .bar:nth-child(#{$i + 1}) {
  margin-left: ($i - .5 * $n) * $bar-w;
  }
}

可以设置一个box-shadow,方便我们看见每一个条形块的起始位置:

条形块的颜色设置

条形块的颜色最左边的颜色为深蓝(#1e3f57),最右边为浅蓝色(#63a6c1)。这里可以使用一个Sass mix()函数。第一个参数为浅蓝,第二个为深蓝以及第三个(称为相对重量)为结果mix中所包含的浅蓝的权重(为%)。

对于第一个条形块,数额为0% - 结果中浅蓝的0%,也就是深蓝。

对于最后一个条形块,数额为100% - 最后结果中(也就是深蓝的0%)浅蓝的100%,也就是浅蓝。

对于其余的条形块,我们需要获取均匀分布的中间值。如果一种有$n个条形块,第一个条形块为0%,最后一个为100%,其余的$n - 1需要均分获取值。

一般来说,索引为$i的条形块的相对权重为$i * 100% / ($n -1),这时也就要添加如下的代码:

$c: #63a6c1 #1e3f57; // 1st = light 2nd = dark

@for $i from 0 to $n {
  // list of mix() arguments for current bar
  $args: append($c, $i * 100% / ($n - 1));

  .bar:nth-child(#{$i + 1}) {
  background: mix($args...);
  }
}

现在条形块看起来如下所示:

探索倒影的各种实现方法

Webkit 浏览器: -webkit-box-reflect

Oh,不是吧!这不是一个标准的属性!我不知道这不是一个标准属性。当第一次接触Safari,我甚至没有听说过CSS。但是,对于Webkit浏览器,它起作用并且运行的很好。它简单易用并且在不支持的浏览器中也不会产生什么副作用,只是不显示倒影而已。

接下来看看它是如何工作的,这里分为了三个步骤:

  • 方向,可以为belowleftaboveright关键字的其中一个
  • 一个可选的偏移,指定倒影应该从该元素多远的边缘开始(这是一个CSS长度值)
  • 一个可选的图像遮罩(可以为CSS渐变)

下面的互动演示说明了这一点(点击方向,偏移量,渐变角度以及结束时的alpha和偏移进行对比观察):

注意linear-gradient()可以由多个终止值或者可以被radial-gradient()所替换

在我们的示例中,我的第一反应是在.loader元素上进行添加:

.loader {
  -webkit-box-reflect: below 0 linear-gradient(rgba(#fff), rgba(#fff, .7));
}

但是,在Webkit浏览器中没有任何反应!

发生了什么?我们对所有的元素进行了绝对定位,并且在包含条形块的.loader元素中没有设置任何明确的尺寸。这导致了产生了一个0x0的元素 - 即widthheight0

现在设置一些明确的尺寸,height为条形块的高($bar-h)width比所有条形块的宽度($n * $bar-w)大一丢丢。这里也暂时设置一个box-shadow便于观察:

$loader-w: $n * $bar-w;

.loader {
  width: $loader-w; height: $bar-h;
  box-shadow: 0 0 0 1px red;
}

当需要高亮一个元素的边缘时,相对于outline而言我更偏爱box-shadow,因为当子元素溢出父元素时,outline在不同的浏览器中表现不一致

CSS倒影那些事儿

box-shadow以及outline在Webkit浏览器以及Edge(top) vs. Firefox(bottom) - 当子元素溢出父元素时,outline表现不一致

添加上上述代码,我们在Webkit浏览器中得到的效果如下:

如果不是Webkit浏览器,得到的效果如下:

CSS倒影那些事儿

指定.loader元素明确的大小后,在Chrome中的呈现结果截图

现在我们可以看见加载的边缘以及一些倒影,但是指定的位置有点不准确。我们想要加载位于水平中间线的底部,所以我们将width向左移动了一半。同时,我们想要条形块的底部和它们的父元素相吻合,这里设置bottom:0

.loader {
    margin-left: -.5 * $loader-w;
}
.bar {
    bottom: 0;
}

现在定位问题解决了吗,得到的结果如下:

CSS倒影那些事儿

Firefox: element() + mask

使用element()创建倒影

element()函数(还处于实验中,目前仅Firefox实现还需添加-moz-前缀)给我们提供了一个图像值,如真正的图像所具有的值(如backgroundborder-image,但是在对于伪内容的值不起作用 - 见bug 1285811)。如果想要看到显示的background或者border-image这里需要获取一个参数也就是元素的id选择器。这允许我们做一些邪恶的事情,如使用控制的图像作为背景。但是如果想要在Firefox中获取一个元素的倒影就会变得十分简单。

关于element()函数你需要了解的是它不是递归函数 - 我们不可以使用函数本身作为它们自身的背景。这使得在一个加载创建一个伪倒影变得十分安全,不需要额外的元素。

好吧,让我们看看如何实现。首先,在.loader元素上设置一个id(假设为明显的loader)。改变样式,从Webkit示例中的最后一个demo中开始。之后在.loader元素上添加::after伪元素,进行绝对定位并进行完全覆盖。

.loader::after {
  position: absolute;
  top: 0; right: 0; bottom: 0; left: 0;
  box-shadow: 0 0 0 2px currentColor;
  color: crimson;
  content: 'REFLECTION';
}

我们还设置了一些额外的临时样式,仅仅是为了看出伪元素的边界以及最终形式的方向,我们希望其倒置:

现在需要思考如何使用::after伪元素对抗它们的底部边缘。为了做到这一点,我们使用了scaleY()转换以及一个transform-origin。下面的互动演示说明了对于不同的scale因素以及transform起点如何进行定向缩放:

注意,scale因素以及transform-origin可能超出demo演示的限制

在我们的示例中,需要一个scaleY(-1)以及伪元素::after底部边缘线的transform-origin

CSS倒影那些事儿

使用scale(-1)转换以及一个适当的transform-origin形成倒影

添加如下代码并且对使用element()函数的伪元素::after设置#loader作为背景。

.loader::after {
  transform-origin: 0 100%;
  transform: scaleY(-1);
  background: -moz-element(#loader);
}

注意,因为一些特殊原因选择器使用了.loader,并且element()函数参数使用了#loader,因为其需要一个id选择器。

添加如上代码后的效果(仅仅为Firefox浏览器)为:

可能不是所有的人都使用Firefox浏览器,这里有一个显示的效果截图:

CSS倒影那些事儿

Firefox下使用element()函数实现的倒影效果

使用遮罩实现倒影的渐变褪色

这里和在Webkit示例中使用的方法一样:使用一个遮罩。在那种情况下,遮罩是-webkit-box-reflect值的一个组件。在这个示例中,我们讨论一下CSS mask属性 - 其值为一个SVG引用:

mask: url(#fader);

#fader元素是一个SVG遮罩元素包含着一个矩形元件。

<svg>
  <mask id='fader' maskContentUnits='objectBoundingBox'>
    <rect width='1' height='1'/>
  </mask>
</svg>

使用Haml可以压缩为:

%svg
  %mask#fader(maskContentUnits='objectBoundingBox')
    %rect(width='1' height='1')

然而,当我们添加了如下代码后,我们的倒影消失了 - 在Firefox可以得以验证:

这是因为默认情况下,SVG图形有一个实心黑色的fill,完全不透明,并且默认情况下遮罩为亮度遮罩。所以我们需要做的给矩形一个fill - 一个SVG linearGradient的引用。

%rect(width='1' height='1' fill='url(#grad)')

SVG linearGradient被定义为两点之间使用x1y1x2以及y2属性进行指定。x1y1是渐变线的起点坐标(0%),x2y2是重点坐标(100%)。如果这些被丢失,默认取值分别为0%0%100%以及0%。代表为应用元素上(默认,gradientUnitsobjectBoundingBox),线的左上(0% 0%)以及右上(100% 0%),也就是默认情况下,渐变是从左向右。

但是在我们的示例中,想要实现渐变始于top终于bottom,所以我们将x2的值由100%改为0%y2的值由0%改为100%。这就会导致应用元素的渐变矢量变为由左上(0% 0%)到左下(0% 100%)。

%linearGradient#grad(x2='0%' y2='100%')

linearGradient元素中,我们至少两个top元素。需要三个参数:offsetstop-color以及stop-opacity

  • offset需要一个a%值,一般在0%100%之间,类似于CSS 渐变示例。也可以设置一个数字值,一般在01之间。
  • stop-color理论上可以为一个关键字,hex,rgb(),hsl()或者hsla()值。实际中,Safari浏览器不支持半透明值,所以如果想要在渐变中设置半透明,我们还应该依赖第三个属性...
  • stop-opacity。取值一般在0(完全透明)和1(完全可视)之间。

我们需要牢记,在应用了渐变遮罩的的伪加载已经通过scale(-1)转换产生了倒影。也就是说,渐变遮罩的底部已经可视。所以我们需要将渐变从头部(目视向下)的完全透明渐变为底部(目视向上)的alpha.7

由于我们的渐变为从上向下,第一个top为完全透明。

%linearGradient#grad(x2='0%' y2='100%')
  %stop(offset='0' stop-color='#fff' stop-opacity='0')
  %stop(offset='1' stop-color='#fff' stop-opacity='.7')

添加线性渐变后,在Firefox中实现的效果如下:

CSS倒影那些事儿

Codepen显示如下:

SVG中渐变问题

在我们的示例中,事情十分简单因为我们的遮罩渐变是垂直的。但是如果渐变不是垂直,或者为水平或者说不是从一个角落到另外一个角落,又会怎么样呢?如果我们想要在某一个角度实现渐变呢?

SVG 渐变还具有一个称为gradientTransform的属性,可以将被x1,x2,y1以及y2属性定义的渐变线旋转。大家可能会想在某一个角度重现CSS渐变是容易实现的。但是...并不是如此!

让我们思考如何实现从金黄到深红的渐变。为了使其清晰,在50%设置一个急剧转变。最初,我们获取一个0deg的CSS渐变版本。也就是渐变从底部(金黄)的0%到顶部(深红)的100%。CSS代码如下:

background-image: 
  linear-gradient(0deg, #e18728 50%, #d14730 0);

如果你不知道CSS线性渐变的工作原理,你可以参考Patrick Brosset书写的文章

这时呈现的效果如下:

为了使用SVG重现这个效果,我们创建了一个渐变,y1100%y20%,以及具有相同值的x1x2(为了方便起见这里设置为0)。这就意味着渐变是垂直的,从底部到顶部。同时,这里将两个top偏移量设置为50%

linearGradient#g(y1='100%' x2='0%' y2='0%'
                 gradientTransform='rotate(0 .5 .5)')
  stop(offset='50%' stop-color='#e18728')
  stop(offset='50%' stop-color='#d14730')

编者说: 我向Ana询问,为什么这里转向使用了Jade,回答:开始使用Haml是为了避免变量循环。这路转向使用Jade是因为允许变量以及相关计算。

现在渐变还没有旋转,gradientTransform属性在这一点的值为(0 .5 .5)。后面的两个值指定渐变旋转点的坐标,和应用渐变的元素相关。0 0表示左上角,1 1表示右下角,.5 .5表示中间。可以在下面的示例中清晰的分辩:

如果想要渐变从左向右,在CSS渐变示例中将角度由0deg改为90deg

background-image: 
  linear-gradient(90deg, #e18728 50%, #d14730 0);

为了使SVG渐变获取相同的效果,我们将gradientTransform的值改为rotate(90 .5 .5) :

linearGradient#g(y1='100%' x2='0%' y2='0%'
                 gradientTransform='rotate(90 .5 .5)')
  // same stops as before

到现在为止,一切都很顺利。看起来使用SVG复制CSS渐变也不是一件很令人头痛的事情。但是,让我们换一个角度。如下所示的互动示例中,左边为CSS渐变,右边为SVG实现的版本。紫色线为渐变线,并且应该为金黄到深红之间的突变线。在CSS以及SVG示例中,拖动滑块改变渐变角度。我们会发现在90deg倍数,会出现错误。

如上所示,当值不是90deg的倍数时,我们没有得到相同的效果。如果将渐变设置为方形,值一致。意味着我们可以将渐变设置在一个较大的方形元素上 - 之后可以应用到实际元素中。但是,后果会导致我们使用使用element()以及mask创建渐变倒影变得些许困难。

Edge: 也全为SVG?

很不幸,以上所提及的方法在Edge均不起作用。不用手动进行复制并且可以在Edge中运行,目前可以想到的方法就是使用SVG创建加载。其优点为跨浏览器兼容。

基本上,我们使用viewBox创建一个SVG元素,其0 0点在中间是固定的。定义一个条形块,其底部边缘在x轴上,左侧边缘在y轴上。之后在#loader中,多次复制(通过SVGuse元素)这个条形块直到满足需求。关于这些条形块的定位处理方式和之前一样。

- var bar_w = 125, bar_h = 5 * bar_w;
- var n = 10;
- var vb_w = n * bar_w;
- var vb_h = 2 * bar_h;
- var vb_x = -.5 * vb_w, vb_y = -.5 * vb_h;

svg(viewBox=[vb_x, vb_y, vb_w, vb_h].join(' '))
  defs
    rect#bar(y=-bar_h width=bar_w height=bar_h)

  g#loader
    - for(var i = 0; i < n; i++) {
      - var x = (i - .5 * n) * bar_w;
      use(xlink:href='#bar' x=x)
    - }

效果如下:

现在已经创建了这些条形块,想要对SVG元素进行定位,这里使用flexbox。同样和之前一样,使这些条形块产生渐变色,SCSS代码如下:

$n: 10;
$c: #63a6c1 #1e3f57;
$bar-w: 1.25em;
$bar-h: 5 * $bar-w;
$loader-w: $n * $bar-w;
$loader-h: 2 * $bar-h;

body {
  display: flex;
  justify-content: center;
  margin: 0;
  height: 100vh;
}

svg {
  align-self: center;
  width: $loader-w; height: $loader-h;
}

@for $i from 0 to $n {
  $args: append($c, $i * 100%/($n - 1));

  [id='loader'] use:nth-child(#{$i + 1}) {
    fill: mix($args...);
  }
}

添加之后的效果如下:

复制#loader群组(再次使用use元素)。使用scale(1 -1)函数并且应用一个mask产生倒影,和之前伪元素方式一致。默认情况下,SVG元素相对于SVG画布的0, 0,这里定位于加载的底部边缘比较有利于形成倒影,并且不需要设置transform-origin

use(xlink:href='#loader' transform='scale(1 -1)')

因为Edge中不支持CSS transforms,这里使用了transform属性 - 如果你想要使用它,需要等到浏览器的支持

现在已经产生了倒影,效果如下:

最后一步就是使用mask进行褪色处理。和之前的方法一样,所以就不再次重复。完整的代码如下:

动画

最初案例中的CSS动画是相对简单的一个,在3D中旋转条形块:

@keyframes bar {
  0% {
  transform: rotate(-.5turn) rotateX(-1turn);
  }
  75%, 100% { transform: none; }
}

这里所有的条形块的动画一样:

animation: bar 3s cubic-bezier(.81, .04, .4, .7) infinite;

我们只是在每一个条形块循环中添加了一个不同的延迟:

animation-delay: $i*50ms;

既然在3D中旋转条形块,需要在loader元素中添加一个perspective

但是需要使用-webkit-box-reflect方法,以便在Webkit浏览器中起作用。

CSS倒影那些事儿

使用-webkit-box-reflect方法在Chrome中的最终效果

同样的,方便观察我们添加了一个图像背景。在Webkit下的演示如下:

我们也想要其在Firefox中正常工作。然而,添加动画代码后,效果好像有些不一致:

CSS倒影那些事儿

Firefox中使用element()以及mask的最初动画版本效果记录

这里有一些问题,在Firefox中可以测出:

第一个问题是倒影越出伪元素的边界。可以通过增加loader元素的尺寸来修正此问题:

$loader-w: ($n + 1) * $bar-w + $bar-h;

但是,对于其余的两个问题我们就会变得有些无能为力 - 当条形块3D旋转时,倒影更新不足够平滑转换;以及perspective特性会造成条形块的消失(查看bug 1282312)。

CSS倒影那些事儿

CSS倒影那些事儿

在线演示(你可以切换perspective观察不同之处):

那么SVG解决方案呢?不幸的是,我们上述所设置的关键帧用于CSS 3D转换动画。在Edge中还不支持用于CSS 转换的SVG元素 - 这也就是之前为什么我们依赖transform元素创建倒影效果。但是transform特性的值在2D中是十分严格的,不使用JavaScript难以对其添加动画效果(这时,你可能会想到SMIT,这是标记所厌恶的,并且在Edge / IE中也不支持,在Chrome中也已经被弃用)。

所以目前现在还没有绝对的解决方案可以使其在所有的浏览器中工作。我们所能做的就是拥有两个loader元素,每一个的条形块的数量一样:

- 2.times do
  .loader
    - 10.times do 
      .bar

条形块的样式和之前的一样,在第二个loader元素使用scale(-1)转换:

.loader:nth-child(2) {
  transform: scaleY(-1);
}

添加动画,得到如下效果;

现在需要对倒影进行褪色处理。不幸的是,为了得到跨浏览器的效果,我们不可以在第二个loader元素上应用mask。Edge目前也不支持HTML元素的遮罩处理,但是你可以加快它的实现进程

我们可以做的就是使用渐变覆盖第二个loader(此处为产生倒影的那一个)。注意这意味着我们不可以使用图片背景。一个固定的背景在这个示例中是十分有限的,你可以选择使用渐变背景。使用::after伪元素实现并使其足够大以便旋转时可以进行覆盖。

$bgc: #eee;
$cover-h: $bar-w + $bar-h;
$cover-w: $n * $bar-w + $cover-h;

html { background: $bgc; }

.loader:nth-child(2)::after {
  margin-left: -.5 * $cover-w;
  width: $cover-w; height: $cover-h;
  background: linear-gradient($bgc $bar-w, rgba($bgc, .3));
  content: '';
}

结果如下:

最后的思考

我们需要一个更好地跨浏览器解决方案。我相信,可以通过不进行复制产生倒影。我们也可以不选择SVG解决方案(也会产生负面的影响)进行倒影的褪色处理并且可以拥有一个背景图片。

哪一个解决方案相对好一点呢?-webkit-box-reflect还是element() + mask?我不知道。我更喜欢可以跨浏览器兼容的解决方案。

曾经我一度认为我不需要额外的元素产生倒影效果,尽管:reflection伪元素听起来不错。可以自由的在不同的方向创建多次倒影,并且以不同的方式进行倒影转换,如在3D中进行旋转或者倾斜。使用element()方法可以实现,这也是为什么我喜欢这种方法的原因。这里不提及使用SVG实现遮罩,那意味着会产生更多复杂的影响。

另一方面,面对这巨大的外界压力,你可能不能花费大量的时间熟悉文章方法背后的复杂性。有时你仅仅需要一个简单的方法得到简单的效果。

扩展阅读

本文根据@ANA TUDOR的《The State of CSS Reflections》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://css-tricks.com/state-css-reflections/

静子

在校学生,本科计算机专业。一个积极进取、爱笑的女生,热爱前端,喜欢与人交流分享。想要通过自己的努力做到心中的那个自己。微博:@静-如秋叶

如需转载,烦请注明出处:https://www.fedev.cn/css/state-css-reflections.htmlUndercover Nike Daybreak Release Date