重现 Twitter 心形动画(使用一个元素、无图片和无 JavaScript)

Avatar of Ana Tudor
Ana Tudor

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

我最近在 CodePen 的精选作品中看到了一个 Twitter 心形动画的重现。如果我有时间,我总是会查看吸引我注意力的演示的代码,看看里面是否有我可以使用或改进的东西。在这种情况下,我惊讶地发现该演示使用了图像精灵。我后来 了解到 Twitter 就是这么做的。当然,它可以不用图片实现,对吧?

我决定尝试一下。我还决定不使用 JavaScript,因为这是一个使用 复选框技巧 的完美案例,该技巧允许您通过表单元素和巧妙的 CSS 创建简单的开关切换。

结果

最终动画的录制

现在让我们看看我是怎么做到的!

查看原始精灵图

原始 Twitter 心形精灵图。 在新标签页中查看。

它有 **29** 帧,这个数量对我来说没有问题,直到涉及到计算。那时它开始变得难看,因为它是一个很大的质数,我不能用像 2、4 或 5 这样的小整数来整除它。哦,好吧……这就是近似值的作用。29 非常接近 2830,其中 284 的倍数(4 * 7 = 28),305 的倍数(5 * 6 = 30)。所以我们可以将这个 29 看作 2830,哪一个最适合我们。

接下来要注意的是精灵图有三个组成部分

  • 心形
  • 心形后面的气泡
  • 心形周围的粒子

这意味着它可以用一个元素及其两个伪元素来实现。心形是元素本身,气泡是 ::before 伪元素,粒子是 ::after 伪元素。

使用复选框技巧

整个心形及其其他部分将是复选框的 <label>。点击标签将切换复选框并允许我们 处理这两种状态。在这种情况下,我们的 HTML 看起来像这样,一个复选框和一个包含 Unicode 心形字符的标签

<input id="toggle-heart" type="checkbox" />
<label for="toggle-heart">❤</label>

让我们将复选框隐藏起来

[id='toggle-heart'] {
  position: absolute;
  left: -100vw;
}

然后,我们根据复选框是否被选中来设置心形的 color 值。我们使用颜色选择器从精灵图中获取实际的值。

[for='toggle-heart'] {
  color: #aab8c2;
}

[id='toggle-heart']:checked + label {
  color: #e2264d;
}

居中并放大

我们还在标签上设置 cursor: pointer 并增加 font-size,因为否则它看起来太小了。

[for='toggle-heart'] {
  font-size: 2em;
  cursor: pointer;
}

然后,我们将它放置在屏幕中央,以便我们更好地查看它。感谢 flexbox!

body {
  display: flex;
  justify-content: center; /* horizontal alignment */
  margin: 0;
  height: 100vh; /* the viewport height */
}

/* vertical alignment, needs the height of 
   the body to be equal to that of the 
   viewport if we want it in the middle */
[for='toggle-heart'] { 
  align-self: center; 
}

现在我们有一个心形,当复选框未选中时为灰色,选中时为深红色

动画化心形的尺寸增长

查看精灵图,我们看到心形从第 2 帧到第 6 帧缩放到 0。第 6 帧之后,它开始增长,然后从某个点开始略微减小。这种增长非常适合使用 easeOutBack 定时函数。我们取增长的开始点为 17.5%,因为这是一个不错的数字,鉴于我们的总帧数,它似乎是一个很好的近似值。现在我们需要决定如何进行此缩放。我们不能使用 scale() 变换,因为这也会影响我们元素的任何后代或伪元素,我们不希望在心形缩小时这些元素也缩放到 0。因此,我们使用 font-size

@keyframes heart { 0%, 17.5% { font-size: 0; } }

[id='toggle-heart']:checked + label {
  will-change: font-size;
  animation: heart 1s cubic-bezier(.17, .89, .32, 1.49);
}

上述代码的结果可以在以下 Pen 中看到

如果我们不包含 0%100% 关键帧,它们将使用我们为该元素设置的值(在我们的例子中为 font-size: 2em)自动生成,或者,如果我们没有设置这些值,则使用默认值(在 font-size 的情况下为 1em)。

气泡

现在让我们继续创建气泡(以及我们接下来将介绍的粒子)的伪元素。我们在心形标签上设置 position: relative,以便我们可以绝对定位它们。我们希望它们位于心形下方,因此我们使用 z-index: -1 来实现这一点。我们希望它们位于中间,因此从 topleft50% 位置开始。气泡和粒子都是圆形的,因此我们给它们 border-radius: 50%。我们将在此处开始使用 SCSS 语法,因为我们最终需要使用它,因为我们需要进行一些计算。

[for='toggle-heart'] {
  position: relative;

  &:before, &:after {
    position: absolute;
    z-index: -1;
    top: 50%; left: 50%;
    border-radius: 50%;
    content: '';
  }
}

查看精灵图,我们看到气泡最大时略大于心形的两倍,因此我们将其直径设为 4.5rem。我们使用 rem 单位,而不是 em,因为元素的 font-size 正在进行动画以更改心形的尺寸。我们调整 ::before 伪元素的尺寸并在中间进行定位。我们还给它一个测试背景,以便查看它是否存在并且外观正确(稍后我们会删除它)

$bubble-d: 4.5rem; // bubble diameter
$bubble-r: .5 * $bubble-d; // bubble-radius

[for='toggle-heart']::before {
  margin: -$bubble-r;
  width: $bubble-d; height: $bubble-d;
  background: gold;
}

到目前为止一切顺利

从第 2 帧到第 5 帧,气泡从无到有增长到其完整尺寸,并从深红色变为紫色。然后,在第 9 帧之前,它在中间形成一个孔,直到这个孔与气泡本身一样大。增长部分看起来像是动画化 scale() 变换可以完成的工作。我们可以通过将 border-width$bubble-r(气泡半径)动画化到 0 来获得增长的孔。请注意,我们还需要在气泡(::before 伪元素)上设置 box-sizing: border-box 以使其正常工作。

[for='toggle-heart']:before {
  box-sizing: border-box;
  border: solid $bubble-r #e2264d;
  transform: scale(0);
}

@keyframes bubble {
  15% {
    border-color: #cc8ef5;
    border-width: $bubble-r;
    transform: scale(1);
  }
  30%, 100% {
    border-color: #cc8ef5;
    border-width: 0;
    transform: scale(1);
  }
}

我们可以使用 mixin 合并关键帧

@mixin bubble($ext) {
  border-color: #cc8ef5;
  border-width: $ext;
  transform: scale(1);
}

@keyframes bubble {
  15% { @include bubble($bubble-r); }
  30%, 100% { @include bubble(0); }
}

我们还使伪元素继承心形动画,将它们都切换到 easeOutCubic 类型的定时函数,并为每个伪元素单独更改 animation-name

[id='toggle-heart']:checked + label {
  &::before, &::after {
    animation: inherit;
    animation-timing-function: cubic-bezier(.21, .61, .35, 1);
  }

  &::before {
    will-change: transform, border-color, border-width;
    animation-name: bubble;
  }

  &::after { animation-name: particles; }
}

我们可以在以下 Pen 中查看上述代码产生的效果

粒子

查看精灵图,我们可以看到我们有七组两圆形粒子,并且这些组分布在一个圆圈上。

精灵图中连续三帧的特写,显示粒子成组分布在心形周围。

它们的变化包括 opacity、位置(因为组所在的圆的半径在增加)和尺寸。我们使用多个盒阴影创建粒子(每个粒子一个),然后我们动画化伪元素的 opacity 以及这些盒阴影的偏移量和扩展量。

我们首先要做的是确定粒子的尺寸,然后调整 ::after 伪元素的尺寸和位置。

$particle-d: 0.375rem;
$particle-r: 0.5 * $particle-d;

[for='toggle-heart']:after {
  margin: -$particle-r;
  width: $particle-d; height: $particle-d;
}

我们将七组粒子分布在一个圆圈上。圆圈上有 360°,如下面的演示所示

我们将这 360° 分成与组数一样多的部分。下面演示中的多边形的每个顶点都将标记一组的位置。

我们顺时针方向移动,从 x 轴的 +(3 点钟方向)开始。如果我们想从 y 轴的 -(12 点钟方向)开始,那么我们需要从对应于每组位置的角度中减去 90°

现在让我们看看我们如何编写代码将组分布在一个圆圈上,该圆圈的半径我们最初取为与气泡的半径($bubble-r)相同,从顶部(12 点钟方向)开始。如果我们考虑在每组的中间只有一个粒子,那么我们的代码应该是

$shadow-list: (); // init shadow list
$n-groups: 7; // number of groups
$group-base-angle: 360deg/$n-groups;
$group-distr-r: $bubble-r; // circular distribution radius for groups

@for $i from 0 to $n-groups {
  // current group angle, starting fron 12 o'clock
  $group-curr-angle: $i*$group-base-angle - 90deg;
  // coords of the central point of current group of particles
  $xg: $group-distr-r*cos($group-curr-angle);
  $yg: $group-distr-r*sin($group-curr-angle);

  // add to shadow list
  $shadow-list: $shadow-list, $xg $yg;
}

在我们的 ::after 伪元素上设置 box-shadow: $shadow-list 会产生以下结果

现在让我们考虑每组有两个粒子的情况。

我们将组中的粒子放置在一个圆圈上(半径为,比如说,等于我们的 ::after 伪元素的直径 – $particle-d),围绕该组的中心点。

接下来我们需要考虑的是起始角度。对于组本身,起始角度是-90°,因为我们希望从顶部开始。对于单个粒子,起始角度是对应于该组的角度(我们用来计算其坐标的角度),加上一个对于围绕心脏的所有粒子都相同的偏移角度。我们将此角度设为60°,因为这样看起来不错。

下面是计算所有粒子位置并在每个位置添加box-shadow的代码

$shadow-list: ();
$n-groups: 7;
$group-base-angle: 360deg/$n-groups;
$group-distr-r: $bubble-r;
$n-particles: 2;
$particle-base-angle: 360deg/$n-particles;
$particle-off-angle: 60deg; // offset angle from radius

@for $i from 0 to $n-groups {
  $group-curr-angle: $i*$group-base-angle - 90deg;
  $xg: $group-distr-r*cos($group-curr-angle);
  $yg: $group-distr-r*sin($group-curr-angle);

  @for $j from 0 to $n-particles {
    $particle-curr-angle: $group-curr-angle + 
      $particle-off-angle + $j*$particle-base-angle;
    // coordinates of curent particle
    $xs: $xg + $particle-d*cos($particle-curr-angle);
    $ys: $yg + $particle-d*sin($particle-curr-angle);

    // add to shadow list
    $shadow-list: $shadow-list, $xs $ys;
  }
}

现在,这将产生以下Pen中所示的效果

彩虹粒子

位置看起来不错,但所有这些阴影都使用了我们为心脏设置的color值。我们可以通过根据其所在组的索引($i)及其在该组中的索引($j)为每个粒子提供一个hsl()值,使它们呈现彩虹效果。因此,我们更改了添加到阴影列表的部分

$shadow-list: $shadow-list, $xs $ys 
  hsl(($i + $j) * $group-base-angle, 100%, 75%);

这个简单的更改使我们得到了彩虹粒子

我们甚至可以在选择色相时引入一定程度的随机性,但我对这个结果非常满意。

在动画化粒子时,我们希望它们从我们现在所处的位置开始,这意味着位于半径为$bubble-r的圆上的组,稍微向外移动一点,比如,直到组位于半径为1.25 * $bubble-r的圆上。这意味着我们需要更改$group-distr-r变量。

同时,我们希望它们从当前的完整尺寸缩小到零。在没有模糊的情况下将盒阴影缩小到零,意味着需要给它们一个负的扩散半径,其绝对值至少等于其设置到的元素或伪元素的最小尺寸的一半。我们:after伪元素的两个尺寸都等于$particle-d(粒子直径),因此我们的扩散半径应该为-$particle-r(粒子半径)。

概括来说,在状态0中,我们有一个半径为$bubble-r的组分布圆和一个为0的扩散半径,而在状态1中,我们有一个半径为1.25 * $bubble-r的组分布圆和一个为-$particle-r的扩散半径。

如果我们使用变量$k表示状态,那么我们有

$group-distr-r: (1 + $k * 0.25) * $bubble-r;
$spread-r: -$k * $particle-r;

这导致我们创建了一个mixin,这样我们就不用写两次@for循环了

@mixin particles($k) {
  $shadow-list: ();
  $n-groups: 7;
  $group-base-angle: 360deg / $n-groups;
  $group-distr-r: (1 + $k * 0.25)*$bubble-r;
  $n-particles: 2;
  $particle-base-angle: 360deg / $n-particles;
  $particle-off-angle: 60deg; // offset angle from radius
  $spread-r: -$k * $particle-r;

  @for $i from 0 to $n-groups {
    $group-curr-angle: $i * $group-base-angle - 90deg;
    $xg: $group-distr-r * cos($group-curr-angle);
    $yg: $group-distr-r * sin($group-curr-angle);

    @for $j from 0 to $n-particles {
      $particle-curr-angle: $group-curr-angle + 
        $particle-off-angle + $j * $particle-base-angle;
      $xs: $xg + $particle-d * cos($particle-curr-angle);
      $ys: $yg + $particle-d * sin($particle-curr-angle);

      $shadow-list: $shadow-list, $xs $ys 0 $spread-r 
        hsl(($i + $j) * $group-base-angle, 100%, 75%);
    }
  }

  box-shadow: $shadow-list;
}

现在让我们再看一下精灵图。粒子直到第7帧才出现。728的四分之一(或25%),非常接近我们实际的帧数(29)。这意味着我们粒子的基本动画看起来像这样

@keyframes particles {
  0%, 20% { opacity: 0; }
  25% {
    opacity: 1;
    @include particles(0);
  }
}

[for='toggle-heart']:after { @include particles(1); }

这可以在以下Pen中看到

调整

它在所有浏览器中看起来都很好,除了Edge/IE,在这些浏览器中,粒子并没有真正缩小到无,它们仍然存在,非常小,几乎看不到,但仍然可见。对此的一个快速解决方案是稍微增加扩散半径的绝对值

$spread-r: -$k * 1.1 * $particle-r;

另一个问题是某些操作系统会将Unicode爱心转换为表情符号。我找到了一个可以防止这种情况发生的解决方案,但它看起来很丑,而且被证明不可靠,所以我最终在复选框未选中时应用了filtergrayscale(1),并在选中时将其删除。

再进行一些调整,例如在body上设置一个漂亮的backgroundfont,并防止爱心被选中,我们得到

辅助功能

这仍然存在一个问题,在这种情况下是一个辅助功能问题:当使用键盘进行导航时,没有视觉线索指示爱心切换是否处于焦点状态(因为我们已将复选框移出了视野)。想到的第一个解决方案是在复选框处于焦点时在爱心上添加一个text-shadow。白色似乎是最佳选择

[id='toggle-heart']:focus + label {
  text-shadow: 
    0 0 3px #fff, 
    0 1px 1px #fff, 0 -1px 1px #fff, 
    1px 0 1px #fff, -1px 0 1px #fff;
}

它看起来与爱心的初始灰色状态对比度不够,所以我最终将精灵图中的灰色更改为更深一些的颜色。

更新:正如David Storey在评论中建议的那样,我还向标签添加了aria-label='like'

最终结果