我最近在 CodePen 的精选作品中看到了一个 Twitter 心形动画的重现。如果我有时间,我总是会查看吸引我注意力的演示的代码,看看里面是否有我可以使用或改进的东西。在这种情况下,我惊讶地发现该演示使用了图像精灵。我后来 了解到 Twitter 就是这么做的。当然,它可以不用图片实现,对吧?
我决定尝试一下。我还决定不使用 JavaScript,因为这是一个使用 复选框技巧 的完美案例,该技巧允许您通过表单元素和巧妙的 CSS 创建简单的开关切换。
结果

现在让我们看看我是怎么做到的!
查看原始精灵图

它有 **29** 帧,这个数量对我来说没有问题,直到涉及到计算。那时它开始变得难看,因为它是一个很大的质数,我不能用像 2、4 或 5 这样的小整数来整除它。哦,好吧……这就是近似值的作用。29
非常接近 28
和 30
,其中 28
是 4
的倍数(4 * 7 = 28
),30
是 5
的倍数(5 * 6 = 30
)。所以我们可以将这个 29
看作 28
或 30
,哪一个最适合我们。
接下来要注意的是精灵图有三个组成部分
- 心形
- 心形后面的气泡
- 心形周围的粒子
这意味着它可以用一个元素及其两个伪元素来实现。心形是元素本身,气泡是 ::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
来实现这一点。我们希望它们位于中间,因此从 top
和 left
的 50%
位置开始。气泡和粒子都是圆形的,因此我们给它们 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
帧才出现。7
是28
的四分之一(或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爱心转换为表情符号。我找到了一个可以防止这种情况发生的解决方案,但它看起来很丑,而且被证明不可靠,所以我最终在复选框未选中时应用了filter
为grayscale(1)
,并在选中时将其删除。
再进行一些调整,例如在body
上设置一个漂亮的background
和font
,并防止爱心被选中,我们得到
辅助功能
这仍然存在一个问题,在这种情况下是一个辅助功能问题:当使用键盘进行导航时,没有视觉线索指示爱心切换是否处于焦点状态(因为我们已将复选框移出了视野)。想到的第一个解决方案是在复选框处于焦点时在爱心上添加一个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'
。
最终结果
两个简单的div;彻底复杂的UI行为。哇!感谢Ana为我们提供了一个非常详细和复杂的示例,说明我们如何使用CSS来完成看似非常复杂的事情。
真是太棒了!!
做得非常好,这仅仅展示了您可以用少量代码创建什么 :)
非常酷的效果,Ana,感谢分享!并不是想贬低这篇文章,但你实际上使用了两个元素。我看到了文章标题,对有人如何只用一个元素、不使用JS、不使用图像/SVG就能做到这一点感到困惑,所以我想提一下,因为感觉有点像标题党。如果输入不是替换元素,并且它们的伪元素可以识别其主机的
:checked
状态,那么可能可以用一个元素来实现。作为这项技术的潜在优化,您可以将标签包裹在输入元素周围,这将消除在各自元素上使用
for
和id
的需要。这将意味着第三个元素,但将消除来自大型Twitter Feed场景的潜在ID命名空间噩梦。再次感谢你写下这篇文章,它很棒!
我一直觉得将标签包裹在输入元素周围有点奇怪,但唉,这似乎是一种相当普遍的做法。
我相信每条推文都会有一个唯一的ID之类的东西,因此可以轻松地像
abc123_like
一样追加它。这将是最不重要的问题。我个人发现从语义的角度来看,将标签包裹在输入元素周围很奇怪且不正确——输入不是标签的一部分。
出于辅助功能原因(更好的屏幕阅读器支持)和辅助功能相关的样式原因(如果您隐藏复选框/单选按钮,以便您可以放置您自己的漂亮样式版本,则不需要JS即可使整个内容可访问),最好不要将标签包裹在输入元素周围。
是的,每条推文都有一个唯一的ID——它是推文URL结尾的数字,如果您使用开发人员工具检查,您可以找到插入到许多ID和数据属性中的该数字。因此,在Twitter之类的场景中,ID绝对不会成为问题。
啊!这太聪明了,我非常喜欢。感谢Ana与我们分享你的过程,这里有很多可以借鉴的地方。干得好!
太棒了!!
哇!Ana,你太棒了!令人印象深刻!
如今使用SASS和CSS可以做到的事情真是令人惊叹……感谢你分享你对这些语言的精通知识……我也要说非常令人印象深刻……
哇——这真是令人印象深刻。我一定会使用这个示例来获取一些SASS创意。干得好!
太棒了,Ana!与原始版本几乎无法区分!
伙计,感谢你使用阴影来实现这一点。阴影太棒了。我使用盒阴影生成了我的头像(是的,也是一个元素)。
这真的很酷,你见过 Gregor Adams 发布的 CSS 分形演示吗?http://codepen.io/collection/tvJqF/
哦,哇,没有,我没有。我喜欢 3D 分形树。DOM 绝对完美地适合这样的分形——它基本上变成了一个合适的场景图。
如何防止 iOS/Mac OS 将 Unicode 心形替换为表情符号字符?
好问题!不知道,我可能不会。我可能会在复选框未选中时应用一个
grayscale()
过滤器,并且可能还会稍微调整一下亮度和饱和度。如果确实需要,也可以在选中状态下使用hue-rotate()
。请注意,标签应该有一个 aria-label 属性以实现屏幕阅读器辅助功能。否则,屏幕阅读器将读取类似“黑色重心,复选框,未选中”的内容。大概在这种情况下,它应该是
aria-label="like"
,因为这是原生应用程序使用的。它们也使用按钮,但我认为如果不使用 JS 就无法做到这一点,即使您在复选框上放置 role="button" 或使用按钮,您仍然需要通过 JS 添加 aria-pressed 状态。
已更新!:)
谢谢
嗨,Ana,
看来您实际上阅读了我 12 月份的文章,并从中获取了一些想法 :-):http://medium.com/@OxyDesign/twitter-s-heart-animation-in-full-css-b1c00ca5b774
同样的挑战是用 CSS/Html 复制它,但有一些区别
我的 html 元素比你的多,但主要是针对心形。我使用了 4 个元素来创建一个非常接近原始形状的形状。这有点棘手,但我不想使用 unicode 解决方案,因为我想确保心形不会根据您使用的设备/操作系统/浏览器/字体而改变(例如,当我用我的 Chrome/OSX 查看你的心形时,它与你的 gif 中的形状不同。可能是因为字体)。
关于粒子,我们使用了相同的概念(多个 box-shadows),但我还对它们进行了动画处理,因为在圆圈逐渐消失的每对粒子中,它们都以不同的速度向外移动,并且一个粒子在另一个粒子收缩时会增长,它们的颜色也会发生变化。
关于无 JS 解决方案,我喜欢著名的复选框技巧(我在许多项目中都使用过它),但在这种情况下,迟早您需要添加一些 JS 来与服务器同步点赞/取消点赞,所以我添加了一行 JS 只是为了切换类以触发动画(在我的 Codepen 中,JS 中的所有其他内容都用于调试和演示目的),实际上 Twitter 使用一个带有 JS 行为的按钮元素(用于触发动画和与服务器同步),没有复选框或表单。
因为我从未收到过你的回复(https://twitter.com/OxyDesign/status/674295377928024064),所以我认为你喜欢它!
粒子的混合效果太酷了!
我记得我去年分叉了这个笔,只是重新设计了心形的形状和环的样式。
(我使用
box-shadow
替换border-width
以提高性能)http://codepen.io/Rplus/pen/advXwj?editors=0100
@Nicolas:我还没看到。:( 在写这篇文章之前,我确实进行过搜索,看看之前是否有人做过,或者这篇文章是否有意义,但没有找到,只找到了一些解释精灵技术的文章。
即使我小心地明确设置了字体,心形在浏览器/操作系统中的显示也确实有所不同。在 IE 中与其他任何东西都大不相同!
但我觉得这是最快的方法,而且我通常喜欢避免任何需要美学意识的事情。这是我完全缺乏的东西,我 100% 是技术人员,0% 是艺术家,我知道如果我去那里,我一定会把事情搞砸。所以我根本不去那里。动画也是一样。我只是想要一些快速且易于计算的东西。我知道我的动画缺乏技巧,之前也有人告诉我过,但考虑到我缺乏艺术感以及我在上面花费的时间很少,我对这个动画的结果还是非常满意的。
完全同意在像 Twitter 这样的情况下使用 JS。如果我是真的为 Twitter 做这个而不是仅仅作为娱乐,我甚至都不会考虑不使用 JS。
@Rplus:这很有趣。我一开始使用
box-shadow
因为它对我来说感觉像个更好的主意,但我对动画性能不满意,所以我切换到了border-width
。在这种情况下,对动画性能也不满意,但仍然比box-shadow
好一点。@Rplus:谢谢!这个函数花了我一段时间来编写并找出粒子的确切行为。很高兴你喜欢它!
关于心形,我同意你的看起来更好,起初我尝试简单地创建一个心形,它看起来比我当前的更好。但后来我尝试使其尽可能地看起来像真实的心形。而真实的心形有一个奇怪的形状 :-)
环状效果很好,它看起来像我的,但如果它性能更佳,那就太好了!(在我的当前电脑上,我无法发现差异,但我相信你)
@Ana:别客气!
我做这种挑战是为了让自己跟上 CSS 的步伐,因为最近几个月和几年我主要从事 Javascript 的工作,并且不希望失去我在这个领域的习惯和知识。但我并没有寻求任何名气(我在 medium 上制作了许多小型 Codepen 和其他技术文章,浏览量从未超过几百次),我最初对它在最初几周的浏览量感到惊讶(目前几乎有 2 万次浏览量/350 个赞,对我来说这简直太大了)。这也让我获得了我的新职位,这对我来说是一个惊人的职业机会,同样也是意料之外的。
我理解你关于艺术方面的观点,这只是我个人,如果它与我想要或想到的不够接近,我就无法入睡,我可以花几个小时来修复精确的大小、精确的动作等等(而那个实际上占用了一些我的夜晚)。也许是因为我以前是设计师,这可能是一个解释 :-)
而且……你主要使用 IE?!?!
我并不主要使用 IE。我只是在所有我能使用的浏览器中测试了心形,包括 IE。
你不必吹嘘(-:) 非常聪明的东西
这是一篇很棒的文章,但对于刚开始设计网站的人来说,这有点多,即使我在这个领域工作了 3 年,有些部分我也没看懂。
太棒了!!文章 :)
这真是太棒了!
我做了一个分叉。它使用了大部分 SCSS 和 HTML 的概念,但没有 ID,因此您可以根据需要重复使用。这个概念非常好,我更喜欢使用 HTML 实体表示心形,我已经调整了尺寸以使其在页面上有很多心形时停止使我的屏幕闪烁。我还使轮廓可配置,因为我不喜欢它,但不想删除它。这完全是个人喜好,而不是原始代码有任何问题,我猜想它需要花费相当长的时间和精力。
http://codepen.io/Lewiscowles1986/pen/bZWQKY?editors=1100
关于 ID 的问题,请参阅我上面的评论——Twitter 已经拥有一个完善的 ID 系统,并且不将输入放在标签内意味着可以更好地支持辅助技术。
此外,为什么要使用 HTML 实体?根据我发现的信息,在这一点上,如果您使用 Unicode 心形,应该不会再遇到麻烦了。而且,Unicode 心形的含义对任何人都一目了然,而我当然无法分辨
❤
在屏幕上可能会产生什么,除非事先知道或进行搜索。震惊。:D
很棒的文章!
我一直想从后端转向前端开发,但这些东西让我惊叹不已——我不确定我是否会想到这种惊人的技术组合。
我想知道你是如何获取原始精灵图的。
这太神奇了,你只需用2个div和一堆CSS就能做到。表示深深的敬意!
哇,令人印象深刻。
巧妙地使用了盒阴影和复杂的UI技术,仅用2个简单的DOM元素就实现了。
喜欢它。
读者christopher hayes写道