我观看了 Kevin Powell 的视频,他在其中使用 CSS 重现了一个不错的打字机动画。它很巧妙,你绝对应该看看,因为里面有一些货真价实的 CSS 技巧。我相信你已经看过其他 CSS 对此的尝试,包括 本网站自己的代码片段。
像 Kevin 一样,我决定重现这个动画,但将其扩展到 JavaScript。这样,我们就有了一些额外的工具,可以使打字感觉更自然,甚至更具活力。许多 CSS 解决方案依赖于基于文本长度的魔法数字,但使用 JavaScript,我们可以制作出能够处理我们抛给它的任何文本的东西。
所以,让我们来做吧。在本教程中,我将展示我们可以通过更改实际文本来自定义多个单词的动画。每次添加新单词时,无需修改代码,因为 JavaScript 会为您完成此操作!
从文本开始
让我们从文本开始。我们使用等宽字体来实现效果。为什么?因为在等宽字体中,每个字符或字母占据相同的水平空间,这在我们使用steps()
动画化文本时会派上用场。当我们已经知道字符的确切宽度并且所有字符都共享相同的宽度时,事情会变得更容易预测。
我们在一个容器内放置了三个元素:一个用于实际文本的元素,一个用于隐藏文本的元素,以及一个用于动画化光标的元素。
<div class="container">
<div class="text_hide"></div>
<div class="text">Typing Animation</div>
<div class="text_cursor"></div>
</div>
我们可以在这里使用::before
和::after
伪元素,但它们对于 JavaScript 来说不是很好。伪元素不是 DOM 的一部分,而是用作 CSS 中为元素设置样式的额外钩子。使用真实元素会更好。
我们完全隐藏了.text_hide
元素后面的文本。这是关键。它是一个空的 div,它扩展到文本的宽度并将其屏蔽,直到动画开始——那时我们开始看到文本从元素后面移出。
为了覆盖整个文本元素,将.text_hide
元素放置在文本元素的顶部,使其具有与文本元素相同的宽度和高度。请记住,将.text_hide
元素的background-color
设置为与文本周围背景完全相同,以便所有内容混合在一起。
.container {
position: relative;
}
.text {
font-family: 'Roboto Mono', monospace;
font-size: 2rem;
}
.text_hide {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: white;
}
光标
接下来,让我们制作那个在文本输入时闪烁的小光标。我们稍后再处理闪烁部分,先专注于光标本身。
让我们创建一个名为.text_cursor
的元素。属性将类似于.text_hide
元素,但有一个细微的差别:我们不设置background-color
,而是将background-color
保持为transparent
(因为它在技术上没有必要),然后在新的.text_cursor
元素的左边缘添加一个边框。
.text_cursor{
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: transparent;
border-left: 3px solid black;
}
现在我们得到了一些看起来像光标的东西,它可以随着文本的移动而移动。
JavaScript 动画
现在到了超级有趣的部分——让我们用 JavaScript 为这些东西制作动画!我们将从将所有内容包装在一个名为typing_animation()
的函数中开始。
function typing_animation(){
// code here
}
typing_animation();
下一个任务是使用split()
方法将文本的每个字符都存储在一个数组中。这会将字符串划分为仅包含一个字符的子字符串,并返回包含所有子字符串的数组。
function typing_animation(){
let text_element = document.querySelector(".text");
let text_array = text_element.innerHTML.split("");
}
例如,如果我们将“Typing Animation”作为字符串,则输出为
![(16) ["T", "y", "p", "i", "n", "g", " ", "A", "n", "i", "m", "a", "t", "i", "o", "n"]](https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/07/s_8EA91DCFAB4CC63A2BECFD2BD0D915F37F564BADB0336D1C310FCB8E38E3C85F_1624639698011_array.jpg?resize=1027%2C54&ssl=1)
我们还可以确定字符串中的字符总数。为了只获取字符串中的单词,我们将split("")
替换为split(" ")
。请注意,两者之间存在差异。这里," "
充当分隔符。每当我们遇到一个空格时,它将终止子字符串并将其存储为数组元素。然后,该过程对整个字符串继续进行。
function typing_animation(){
let text_element = document.querySelector(".text");
let text_array = text_element.innerHTML.split("");
let all_words = text_element.innerHTML.split(" ");
}
例如,对于字符串“Typing Animation”,输出将为:
![(2") ["Typing", "Animation"]](https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/07/s_8EA91DCFAB4CC63A2BECFD2BD0D915F37F564BADB0336D1C310FCB8E38E3C85F_1624640608689_words.jpg?resize=878%2C43&ssl=1)
现在,让我们计算整个字符串的长度以及每个单词的长度。
function typing_animation() {
let text_element = document.querySelector(".text");
let text_array = text_element.innerHTML.split("");
let all_words = text_element.innerHTML.split(" ");
let text_len = text_array.length;
const word_len = all_words.map((word) => {
return word.length;
});
}
要获取整个字符串的长度,我们必须访问包含所有字符作为单个元素的数组的长度。如果我们说的是单个单词的长度,那么我们可以使用map()
方法,它一次访问all_words
数组中的一个单词,然后将单词的长度存储到一个名为word_len
的新数组中。这两个数组具有相同数量的元素,但一个包含实际单词作为元素,另一个包含单词的长度作为元素。

现在我们可以制作动画了!我们正在使用 Web Animation API,因为我们在这里使用纯 JavaScript——在本例中,我们不使用 CSS 动画。
首先,让我们为光标制作动画。它需要无限地闪烁。我们需要关键帧和动画属性,这两者都将存储在自己的 JavaScript 对象中。以下是关键帧
document.querySelector(".text_cursor").animate([
{
opacity: 0
},
{
opacity: 0, offset: 0.7
},
{
opacity: 1
}
], cursor_timings);
我们已经定义了三个关键帧作为对象,并将其存储在一个数组中。术语offset: 0.7
仅表示在动画完成 70% 后,不透明度将从 0 转换为 1。
现在,我们必须定义动画属性。为此,让我们创建一个 JavaScript 对象来将它们放在一起
let cursor_timings = {
duration: 700, // milliseconds (0.7 seconds)
iterations: Infinity, // number of times the animation will work
easing: 'cubic-bezier(0,.26,.44,.93)' // timing-function
}
我们可以像这样给动画命名
let animation = document.querySelector(".text_cursor").animate([
// keyframes
], //properties);
以下是我们到目前为止所做工作的演示
太好了!现在,让我们为.text_hide
元素制作动画,它忠于其名称,隐藏了文本。我们为该元素定义动画属性
let timings = {
easing: `steps(${Number(word_len[0])}, end)`,
delay: 2000, // milliseconds
duration: 2000, // milliseconds
fill: 'forwards'
}
easing
属性定义了动画速率随时间变化的方式。这里,我们使用了steps()
时间函数。这会使元素以离散段而不是平滑连续的动画进行动画——你知道,为了获得更自然的打字运动。例如,动画持续时间为两秒,因此steps()
函数以9
个步骤(“Animation”中的每个字符一个步骤)为两秒钟对元素进行动画处理,其中每个步骤的持续时间为2/9 = 0.22
秒。
end
参数使元素保持其初始状态,直到第一个步骤的持续时间完成。此参数是可选的,其默认值为end
。如果您想深入了解steps()
,则可以参考 Joni Trythall 的这篇很棒的 文章。
fill
属性与 CSS 中的animation-fill-mode
属性相同。通过将其值设置为forwards
,动画完成后,元素将停留在最后一个关键帧定义的位置。
接下来,我们将定义关键帧。
let reveal_animation_1 = document.querySelector(".text_hide").animate([
{ left: '0%' },
{ left: `${(100 / text_len) * (word_len[0])}%` }
], timings);
现在我们只对一个单词进行动画处理。稍后,我们将了解如何对多个单词进行动画处理。
最后一个关键帧至关重要。假设我们要为“Animation”这个词制作动画。它的长度为9
(因为有九个字符),并且由于我们的typing_animation()
函数,我们知道它被存储为一个变量。声明100/text_len
得到100/9
,即 11.11%,这是“Animation”这个词中每个字符的宽度。这意味着每个字符的宽度是整个单词宽度的 11.11%。如果我们将此值乘以第一个单词的长度(在本例中为9
),则得到 100%。是的,我们可以直接写 100% 而不是做所有这些事情。但是,当我们为多个单词制作动画时,此逻辑将对我们有所帮助。
所有这些的结果是.text_hide
元素从left: 0%
动画到left: 100%
。换句话说,随着它的移动,此元素的宽度从 100% 减少到 0%。
我们还必须将相同的动画添加到.text_cursor
元素,因为我们希望它与.text_hide
元素一起从左到右过渡。
耶!我们为单个单词制作了动画。如果我们想为多个单词制作动画怎么办?接下来让我们这样做。
为多个单词制作动画
假设我们有两个我们想要打出的单词,可能是“Typing Animation”。我们通过遵循上次相同的步骤为第一个单词制作动画。但是,这次我们正在更改动画属性中的缓动函数值。
let timings = {
easing: `steps(${Number(word_len[0] + 1)}, end)`,
delay: 2000,
duration: 2000,
fill: 'forwards'
}
我们将数字增加了一个步长。为什么?好吧,单词后面的空格怎么办?我们必须考虑这一点。但是,如果一个句子中只有一个单词怎么办?为此,我们将编写一个if
条件,如果单词数等于 1,则为steps(${Number(word_len[0])}, end)
。如果单词数不等于 1,则为steps(${Number(word_len[0] + 1)}, end)
。
function typing_animation() {
let text_element = document.querySelector(".text");
let text_array = text_element.innerHTML.split("");
let all_words = text_element.innerHTML.split(" ");
let text_len = text_array.length;
const word_len = all_words.map((word) => {
return word.length;
})
let timings = {
easing: `steps(${Number(word_len[0])}, end)`,
delay: 2000,
duration: 2000,
fill: 'forwards'
}
let cursor_timings = {
duration: 700,
iterations: Infinity,
easing: 'cubic-bezier(0,.26,.44,.93)'
}
document.querySelector(".text_cursor").animate([
{
opacity: 0
},
{
opacity: 0, offset: 0.7
},
{
opacity: 1
}
], cursor_timings);
if (all_words.length == 1) {
timings.easing = `steps(${Number(word_len[0])}, end)`;
let reveal_animation_1 = document.querySelector(".text_hide").animate([
{ left: '0%' },
{ left: `${(100 / text_len) * (word_len[0])}%` }
], timings);
document.querySelector(".text_cursor").animate([
{ left: '0%' },
{ left: `${(100 / text_len) * (word_len[0])}%` }
], timings);
} else {
document.querySelector(".text_hide").animate([
{ left: '0%' },
{ left: `${(100 / text_len) * (word_len[0] + 1)}%` }
], timings);
document.querySelector(".text_cursor").animate([
{ left: '0%' },
{ left: `${(100 / text_len) * (word_len[0] + 1)}%` }
], timings);
}
}
typing_animation();
对于多个单词,我们使用一个for
循环来迭代并为第一个单词之后的每个单词设置动画。
for(let i = 1; i < all_words.length; i++){
// code
}
为什么我们将i
设为1
?因为当这个for
循环执行时,第一个单词的动画已经完成了。
接下来,我们将访问相应单词的长度。
for(let i = 1; i < all_words.length; i++){
const single_word_len = word_len[i];
}
让我们也为第一个单词之后的每个单词定义动画属性。
// the following code goes inside the for loop
let timings_2 = {
easing: `steps(${Number(single_word_len + 1)}, end)`,
delay: (2 * (i + 1) + (2 * i)) * (1000),
duration: 2000,
fill: 'forwards'
}
这里最重要的事情是delay
属性。如你所知,对于第一个单词,我们只需将delay
属性设置为两秒;但现在我们必须以动态的方式增加后续单词的延迟。
第一个单词的延迟为两秒。其动画持续时间也为两秒,总共四秒。但在第一个和第二个单词的动画之间应该有一些间隔,使动画更逼真。我们可以做的是在每个单词之间增加两秒的延迟而不是一秒。这使得第二个单词的总延迟为2 + 2 + 2
,即六秒。类似地,第三个单词的总延迟为 10 秒,依此类推。
此模式的函数如下所示
(2 * (i + 1) + (2 * i)) * (1000)
…我们将乘以 1000 将秒转换为毫秒。
单词的长度 | 一个字符动画所需的时间 |
6 | 2/6 = 0.33 秒 |
8 | 2/8 = 0.25 秒 |
9 | 2/9 = 0.22 秒 |
12 | 2/12 = 0.17 秒 |
单词越长,显示速度越快。为什么?因为无论单词有多长,持续时间都保持不变。尝试调整持续时间和延迟属性以获得最佳效果。
还记得我们通过考虑单词后一个空格来更改steps()
值吗?同样,句子中的最后一个单词后面没有空格,因此我们应该在另一个if
语句中考虑这一点。
// the following code goes inside the for loop
if (i == (all_words.length - 1)) {
timings_2.easing = `steps(${Number(single_word_len)}, end)`;
let reveal_animation_2 = document.querySelector(".text_hide").animate([
{ left: `${left_instance}%` },
{ left: `${left_instance + ((100 / text_len) * (word_len[i]))}%` }
], timings_2);
document.querySelector(".text_cursor").animate([
{ left: `${left_instance}%` },
{ left: `${left_instance + ((100 / text_len) * (word_len[i]))}%` }
], timings_2);
} else {
document.querySelector(".text_hide").animate([
{ left: `${left_instance}%` },
{ left: `${left_instance + ((100 / text_len) * (word_len[i] + 1))}%` }
], timings_2);
document.querySelector(".text_cursor").animate([
{ left: `${left_instance}%` },
{ left: `${left_instance + ((100 / text_len) * (word_len[i] + 1))}%` }
], timings_2);
}
left_instance
变量是什么?我们还没有讨论过它,但它是我们正在做的事情中最关键的部分。让我解释一下。
0%
是第一个单词left
属性的初始值。但是,第二个单词的初始值应等于第一个单词的最终left
属性值。
if (i == 1) {
var left_instance = (100 / text_len) * (word_len[i - 1] + 1);
}
word_len[i - 1] + 1
指的是前一个单词的长度(包括一个空格)。
我们有两个单词,“Typing Animation”。这使得text_len
等于16
,这意味着每个字符占完整宽度的 6.25%(100/text_len = 100/16
),乘以第一个单词的长度7
。所有这些计算得到43.75
,实际上是第一个单词的宽度。换句话说,第一个单词的宽度是整个字符串宽度的43.75%
。这意味着第二个单词从第一个单词结束的地方开始动画。
最后,在for
循环的末尾更新left_instance
变量。
left_instance = left_instance + ((100 / text_len) * (word_len[i] + 1));
你现在可以在 HTML 中输入任意多个单词,动画就会正常工作!
额外内容
你有没有注意到动画只运行一次?如果我们想无限循环它怎么办?这是可能的。
就是这样:一个更强大的打字动画的 JavaScript 版本。CSS 也有方法(甚至多种方法)来做同样的事情,这真的很酷。在某些情况下,CSS 甚至可能是更好的方法。但是,当我们需要超出 CSS 处理范围的功能增强时,添加一些 JavaScript 就能很好地解决问题。在这种情况下,我们为所有单词添加了支持,无论它们包含多少个字符,以及动画多个单词的功能。并且,通过在单词之间添加一个小额的额外延迟,我们得到了一个非常自然的动画效果。
就是这样,希望你发现这很有趣!就此结束。
很棒的实现。我尝试过对突出显示的文本(例如代码块)做同样的事情,但我最多只能做到这样 https://dev.to/genijaho/typewriter-animation-using-vanilla-js-and-highlight-js-1ecc。它没有打字机的真实感觉,但这就是我的 JS 技能所能达到的程度。
在这一点上,你可以让 JavaScript 真正地打出文字,这样你就无需使用等宽字体或纯色背景。
是的,你可以,但我的主要目的是使用 JavaScript 重新创建 Kevin Powell 的动画。你总是有多种方法可以选择!
我喜欢这个。我做过类似的事情,但试图让打字感觉更像是有人在实时为你打字,通过引入一些随机性。如果有人感兴趣,我很乐意分享我的代码。(它已经很旧了,我用 CoffeeScript 编写的,需要重写。)我的版本可以在我的非常旧的首页上看到
https://hello.mckelveycreative.com/
嗨,我在你的网页上观看了整个动画。这真是一种很棒的体验,我被迷住了,总是渴望看到接下来会发生什么。做得非常好!
不错!唯一缺少的是光标。 :)
太棒了!我会在我的作品集网站上尝试一下!
很棒的动画。遗憾的是它在移动设备的垂直方向上不起作用。
一旦需要两行,动画就会显示半个字母。
观看起来仍然很有趣。
嗨,Sebastian,确实它**在较小的视口尺寸上不起作用**。为了克服这种情况,你可以为某些单词创建单独的
div
,并在早期动画完成后使用 JavaScript 中的.onfinish
方法对其进行动画处理。带有光标看起来真的很棒(比 Kevin 的更赏心悦目),
但没有达到“处理你扔给它的任何东西”的承诺。
例如,换行文本会导致奇怪的效果。
你为什么选择这种复杂的方式?
保持简单 https://jsfiddle.net/a1ws4poL/