利用CSS和JavaScript创建水平时间线
Chinese (Simplified) (中文(简体)) translation by Fish Zou (you can also view the original English article)
在前一篇文章中,我向你展示了如何从头开始创造一条响应式的垂直时间线。今天,我将介绍创建相关水平 时间线的过程。
像往常一样,为了获得一个我们将创造的东西的最初想法,浏览一下相关的CodePen示范(为了更好的体验,检查下更大的版本)
我们有很多要去介绍,所以开始吧!
1.HTML标记
这个标记与我们为垂直时间线定义的标记相同,除了三个小事情:
- 我们使用有序列表代替无序列表,因为它在语义上更正确。
- 这里有一个额外的列表项(最后一个),它是空的。在接下来的部分,我们将讨论原因。
- 这里有一个额外的元素(即
.arrows
),它负责时间线导航。
这是所需的标记:
1 |
<section class="timeline"> |
2 |
<ol>
|
3 |
<li>
|
4 |
<div>
|
5 |
<time>1934</time> |
6 |
Some content here |
7 |
</div>
|
8 |
</li>
|
9 |
|
10 |
<!-- more list items here -->
|
11 |
|
12 |
<li></li>
|
13 |
</ol>
|
14 |
|
15 |
<div class="arrows"> |
16 |
<button class="arrow arrow__prev disabled" disabled> |
17 |
<img src="arrow_prev.svg" alt="prev timeline arrow"> |
18 |
</button>
|
19 |
<button class="arrow arrow__next"> |
20 |
<img src="arrow_next.svg" alt="next timeline arrow"> |
21 |
</button>
|
22 |
</div>
|
23 |
</section>
|
时间轴的初始状态看起来像这样:
2.添加CSS初始化样式
为了简便起见,在省略了一些基本的字体样式,颜色样式等之后,我们指定了一些结构化CSS规则:
1 |
.timeline { |
2 |
white-space: nowrap; |
3 |
overflow-x: hidden; |
4 |
}
|
5 |
|
6 |
.timeline ol { |
7 |
font-size: 0; |
8 |
width: 100vw; |
9 |
padding: 250px 0; |
10 |
transition: all 1s; |
11 |
}
|
12 |
|
13 |
.timeline ol li { |
14 |
position: relative; |
15 |
display: inline-block; |
16 |
list-style-type: none; |
17 |
width: 160px; |
18 |
height: 3px; |
19 |
background: #fff; |
20 |
}
|
21 |
|
22 |
.timeline ol li:last-child { |
23 |
width: 280px; |
24 |
}
|
25 |
|
26 |
.timeline ol li:not(:first-child) { |
27 |
margin-left: 14px; |
28 |
}
|
29 |
|
30 |
.timeline ol li:not(:last-child)::after { |
31 |
content: ''; |
32 |
position: absolute; |
33 |
top: 50%; |
34 |
left: calc(100% + 1px); |
35 |
bottom: 0; |
36 |
width: 12px; |
37 |
height: 12px; |
38 |
transform: translateY(-50%); |
39 |
border-radius: 50%; |
40 |
background: #F45B69; |
41 |
}
|
最重要的是,在这里,你会注意两件事:
- 我们在列表的顶部及底部分配大的内填充。再次申明,我们将在下一部分解释为什么这么做。
- 像你在下面的案例中将会注意到的一样,我们不能看到所有的列表项,因为列表有
width:100vw
,它的父级有overflow-x:hidden
。 这有效的给列表项带上“面具”。但不管怎样,由于时间线导航的存在,我们稍后还是可以浏览这些项目的。
有了这些恰当的规则,以下是时间线的当前状态(没有任何实际内容,以保持清晰):
3.时间线元素样式
此时,我们将风格化那些既是列表项的一部分又是::before
伪元素的div
元素(从现在开始我们将称呼他们为“时间线元素”)。
此外,我们将使用:nth-child(odd)
和:nth-child(even)
CSS伪类来区分基数行和偶数行的div的样式。
这是时间线元素的常见样式:
1 |
.timeline ol li div { |
2 |
position: absolute; |
3 |
left: calc(100% + 7px); |
4 |
width: 280px; |
5 |
padding: 15px; |
6 |
font-size: 1rem; |
7 |
white-space: normal; |
8 |
color: black; |
9 |
background: white; |
10 |
}
|
11 |
|
12 |
.timeline ol li div::before { |
13 |
content: ''; |
14 |
position: absolute; |
15 |
top: 100%; |
16 |
left: 0; |
17 |
width: 0; |
18 |
height: 0; |
19 |
border-style: solid; |
20 |
}
|
接着,基数行的一些样式:
1 |
.timeline ol li:nth-child(odd) div { |
2 |
top: -16px; |
3 |
transform: translateY(-100%); |
4 |
}
|
5 |
|
6 |
.timeline ol li:nth-child(odd) div::before { |
7 |
top: 100%; |
8 |
border-width: 8px 8px 0 0; |
9 |
border-color: white transparent transparent transparent; |
10 |
}
|
最后,还有偶数行的一些样式:
1 |
.timeline ol li:nth-child(even) div { |
2 |
top: calc(100% + 16px); |
3 |
}
|
4 |
|
5 |
.timeline ol li:nth-child(even) div::before { |
6 |
top: -8px; |
7 |
border-width: 8px 0 0 8px; |
8 |
border-color: transparent transparent transparent white; |
9 |
}
|
再次添加内容,时间线的新状态如下:
你可能已经注意到,时间线元素是绝对定位的。这意味着他们不是一般的文件流。 考虑到这一点,为了确保整个时间线的展示,我们为列表的顶部和底部设置大的内边距值。如果我们不采用内边距,时间线将被裁剪:



4.时间线导航样式
现在,是时候风格化导航按钮。记住,我们使往回的箭头无效,并赋予它disabled
类。
下面是相关的CSS样式:
1 |
.timeline .arrows { |
2 |
display: flex; |
3 |
justify-content: center; |
4 |
margin-bottom: 20px; |
5 |
}
|
6 |
|
7 |
.timeline .arrows .arrow__prev { |
8 |
margin-right: 20px; |
9 |
}
|
10 |
|
11 |
.timeline .disabled { |
12 |
opacity: .5; |
13 |
}
|
14 |
|
15 |
.timeline .arrows img { |
16 |
width: 45px; |
17 |
height: 45px; |
18 |
}
|
上面的规则让我们做出了下面的时间线:
5.添加交互
时间线的基础结构搭建好了。让我们为它添加一些交互效果。
变量
重要的事情先做,我们先设置一组稍后会用到的变量。
1 |
const timeline = document.querySelector(".timeline ol"), |
2 |
elH = document.querySelectorAll(".timeline li > div"), |
3 |
arrows = document.querySelectorAll(".timeline .arrows .arrow"), |
4 |
arrowPrev = document.querySelector(".timeline .arrows .arrow__prev"), |
5 |
arrowNext = document.querySelector(".timeline .arrows .arrow__next"), |
6 |
firstItem = document.querySelector(".timeline li:first-child"), |
7 |
lastItem = document.querySelector(".timeline li:last-child"), |
8 |
xScrolling = 280, |
9 |
disabledClass = "disabled"; |
初始化事物
当所有页面内容加载好后,init
函数被调用。
1 |
window.addEventListener("load", init); |
这个函数将触发四个子函数:
1 |
function init() { |
2 |
setEqualHeights(elH); |
3 |
animateTl(xScrolling, arrows, timeline); |
4 |
setSwipeFn(timeline, arrowPrev, arrowNext); |
5 |
setKeyboardFn(arrowPrev, arrowNext); |
6 |
}
|
我们立马会看到,每个函数都完成了某一任务。
等高时间线元素
如果返回到最近示例,你会注意到时间线元素并不等高。虽然这不影响时间线的主要功能,但是如果所有元素等高的话,你可能会更喜欢它。 为了实现这点,我们可以通过CSS赋予他们一个固定高度或通过JavaScript赋予一个和最高元素相同高度的动态高度。
第二个选项更灵活、稳定,所以这里有一个实现该行为的函数:
1 |
function setEqualHeights(el) { |
2 |
let counter = 0; |
3 |
for (let i = 0; i < el.length; i++) { |
4 |
const singleHeight = el[i].offsetHeight; |
5 |
|
6 |
if (counter < singleHeight) { |
7 |
counter = singleHeight; |
8 |
}
|
9 |
}
|
10 |
|
11 |
for (let i = 0; i < el.length; i++) { |
12 |
el[i].style.height = `${counter}px`; |
13 |
}
|
14 |
}
|
该函数检索最高时间线元素的高度,并将其设为所有元素的高度。
示例展现如下:
6.时间线动画化
现在,让我们聚焦于时间线动画。我们将一步步建立实现该行为的函数。
首先,我们为时间线按钮做一个点击事件监听器来记录:
1 |
function animateTl(scrolling, el, tl) { |
2 |
for (let i = 0; i < el.length; i++) { |
3 |
el[i].addEventListener("click", function() { |
4 |
// code here
|
5 |
});
|
6 |
}
|
7 |
}
|
当按钮每次被点击,我们会检查时间线按钮是否处于禁用状态。如果它们不是被禁的,我们要禁用它们。这可确保只有动画完成后才能点击这两个按钮。
所以,从代码而言,单击处理程序最初包含这些行:
1 |
if (!arrowPrev.disabled) { |
2 |
arrowPrev.disabled = true; |
3 |
}
|
4 |
|
5 |
if (!arrowNext.disabled) { |
6 |
arrowNext.disabled = true; |
7 |
}
|
接下来的步骤如下:
- 检查看看这是否是我们第一次点击按钮。再次提醒,记住Previous按钮默认是禁用的,所以我们最初唯一可点击的按钮是Next按钮。
- 如果这是第一次,我们使用
transform
属性来向右移动时间线280px。xScrolling
变量值决定移动量。 - 相反,如果我们已经点击过按钮,检索了当前时间线的
transform
值,增加或删除移动所需的量(即280px)。这样,只要我们单击Previous按钮,transform
属性的值就会减少,时间线就会从左向右移动。 然而,当点击next按钮时,transform
属性值将增加,时间线从右向左移动。
实现此功能的代码如下:
1 |
let counter = 0; |
2 |
for (let i = 0; i < el.length; i++) { |
3 |
el[i].addEventListener("click", function() { |
4 |
// other code here
|
5 |
|
6 |
const sign = (this.classList.contains("arrow__prev")) ? "" : "-"; |
7 |
if (counter === 0) { |
8 |
tl.style.transform = `translateX(-${scrolling}px)`; |
9 |
} else { |
10 |
const tlStyle = getComputedStyle(tl); |
11 |
// add more browser prefixes if needed here
|
12 |
const tlTransform = tlStyle.getPropertyValue("-webkit-transform") || tlStyle.getPropertyValue("transform"); |
13 |
const values = parseInt(tlTransform.split(",")[4]) + parseInt(`${sign}${scrolling}`); |
14 |
tl.style.transform = `translateX(${values}px)`; |
15 |
}
|
16 |
counter++; |
17 |
});
|
18 |
}
|
干得漂亮! 我们刚刚定义了一个动画化时间线的方法。接下来的挑战是找出动画什么时候该停止。以下是我们的方法:
- 当第一个时间线元素变得完全可见,这意味着我们已经到达时间线的开始。因此我们要禁用previous按钮,还要确保next按钮可用。
- 当最后一个元素变得完全可见时,这意味着我们已经达到了时间线的结尾。因此,我们将禁用next按钮,同时确保previous按钮可用。
记得最后一个元素是一个宽度和时间线元素宽度(即280px)一致的空元素。我们赋予它这个值(或更高的)是因为我们想确保next按钮禁用前最后一个时间元素可见。
要检测目标元素在当前视口是否完全可见,我们将利用我们在垂直时间线课程中使用过的相同的代码。来自堆栈溢出线程的代码如下:
1 |
function isElementInViewport(el) { |
2 |
const rect = el.getBoundingClientRect(); |
3 |
return ( |
4 |
rect.top >= 0 && |
5 |
rect.left >= 0 && |
6 |
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && |
7 |
rect.right <= (window.innerWidth || document.documentElement.clientWidth) |
8 |
);
|
9 |
}
|
除了上述功能,我们定义了另一个帮手:
1 |
function setBtnState(el, flag = true) { |
2 |
if (flag) { |
3 |
el.classList.add(disabledClass); |
4 |
} else { |
5 |
if (el.classList.contains(disabledClass)) { |
6 |
el.classList.remove(disabledClass); |
7 |
}
|
8 |
el.disabled = false; |
9 |
}
|
10 |
}
|
此功能从一个基于flag
参数值的元素中增加或删除disabled
类。此外,它可以改变元素的禁用状态。
据上所述,我们编写的用于检查动画是否应该停止的代码如下:
1 |
for (let i = 0; i < el.length; i++) { |
2 |
el[i].addEventListener("click", function() { |
3 |
// other code here
|
4 |
|
5 |
// code for stopping the animation
|
6 |
setTimeout(() => { |
7 |
isElementInViewport(firstItem) ? setBtnState(arrowPrev) : setBtnState(arrowPrev, false); |
8 |
isElementInViewport(lastItem) ? setBtnState(arrowNext) : setBtnState(arrowNext, false); |
9 |
}, 1100); |
10 |
|
11 |
// other code here
|
12 |
});
|
13 |
}
|
注意,在执行此代码之前有一个1.1秒的延迟。为什么会发生这样的事情?
如果回去看下CSS代码,我们会看到这条规定:
1 |
.timeline ol { |
2 |
transition: all 1s; |
3 |
}
|
所以,时间轴动画需要1秒的时间来完成。它一完成,等待100毫秒,然后执行我们的检查。
下面是带动画的时间线:
7.增加滑动支持
到目前为止,时间线不响应任何触摸事件。如果我们添加该功能,将会更好。 为了实现它,我们可以自己写下JavaScript来实现或使用一个既存的相关的库(比如:Hammer.js,TouchSwipe.js)。
为了我们的实例,我们将保持简明并使用Hammer.js,那么首先,我们把以下库包含进代码:



然后申明关联函数:
1 |
function setSwipeFn(tl, prev, next) { |
2 |
const hammer = new Hammer(tl); |
3 |
hammer.on("swipeleft", () => next.click()); |
4 |
hammer.on("swiperight", () => prev.click()); |
5 |
}
|
在上方函数内,我们实现了以下内容:
- 创建一个点击例子。
- 为
swipeleft
和swiperight
事件进行登记操作。 - 当我们向左扫过时间线,触发点击到下一个的按钮,时间轴因此从右向左活动。
- 当我们向右扫过时间线,触发点击先前的按钮,时间线因此从左向右活动。
带滑动支持的时间线,如下:
添加键盘导航
通过提供键盘导航支持,让我们进一步提升用户体验。我们的目标:
- 当按下左箭头键或右箭头键时,该文件应当滚动到时间线上的位置(如果另一页的部分是当前可见的)。这可以确保整个时间线是可见的。
- 具体来说,当按下左箭头键,时间线应当从左向右动画。
- 同样,当按下右箭头键,时间线应当从右向左动画。
关联函数如下所示:
1 |
function setKeyboardFn(prev, next) { |
2 |
document.addEventListener("keydown", (e) => { |
3 |
if ((e.which === 37) || (e.which === 39)) { |
4 |
const timelineOfTop = timeline.offsetTop; |
5 |
const y = window.pageYOffset; |
6 |
if (timelineOfTop !== y) { |
7 |
window.scrollTo(0, timelineOfTop); |
8 |
}
|
9 |
if (e.which === 37) { |
10 |
prev.click(); |
11 |
} else if (e.which === 39) { |
12 |
next.click(); |
13 |
}
|
14 |
}
|
15 |
});
|
16 |
}
|
带键盘支持的时间线:
8.去响应
我们差不多要完成了!最后且重要的是,让我们确保时间线响应。当视口小于600px,它应当有以下堆叠式布局:



因为我们使用的是桌面第一的方法,我们必须重写CSS规则,如下:
1 |
@media screen and (max-width: 599px) { |
2 |
.timeline ol, |
3 |
.timeline ol li { |
4 |
width: auto; |
5 |
}
|
6 |
|
7 |
.timeline ol { |
8 |
padding: 0; |
9 |
transform: none !important; |
10 |
}
|
11 |
|
12 |
.timeline ol li { |
13 |
display: block; |
14 |
height: auto; |
15 |
background: transparent; |
16 |
}
|
17 |
|
18 |
.timeline ol li:first-child { |
19 |
margin-top: 25px; |
20 |
}
|
21 |
|
22 |
.timeline ol li:not(:first-child) { |
23 |
margin-left: auto; |
24 |
}
|
25 |
|
26 |
.timeline ol li div { |
27 |
width: 94%; |
28 |
height: auto !important; |
29 |
margin: 0 auto 25px; |
30 |
}
|
31 |
|
32 |
.timeline ol li:nth-child div { |
33 |
position: static; |
34 |
}
|
35 |
|
36 |
.timeline ol li:nth-child(odd) div { |
37 |
transform: none; |
38 |
}
|
39 |
|
40 |
.timeline ol li:nth-child(odd) div::before, |
41 |
.timeline ol li:nth-child(even) div::before { |
42 |
left: 50%; |
43 |
top: 100%; |
44 |
transform: translateX(-50%); |
45 |
border: none; |
46 |
border-left: 1px solid white; |
47 |
height: 25px; |
48 |
}
|
49 |
|
50 |
.timeline ol li:last-child, |
51 |
.timeline ol li:nth-last-child(2) div::before, |
52 |
.timeline ol li:not(:last-child)::after, |
53 |
.timeline .arrows { |
54 |
display: none; |
55 |
}
|
56 |
}
|
注:以上的两个规则,我们已经通过JavaScript使用了!important
规则来覆盖已应用的相关内联样式。
我们时间线的最终状态:
浏览器支持
在所有的浏览器和设备上,实例运行良好。另外,你可能注意到了,我们使用Babel来编译我们的ES6至ES5代码。
在测试时,我唯一遇到的小问题是当时间线被激活时的文本渲染变化。虽然在栈溢出线程中尝试了各种各样的被推荐的方法,但我并没有找到一种可以适应所有操作程序和浏览器的直接了当的方法。 因此,请记住,随着时间线的变化,你可能会看到小字体渲染问题。
结论
在这个相当充实的教程中,我们从一个简单的有序列表开始并制作了一个响应式水平时间线。不用怀疑,我们覆盖了大量有趣的东西,但是我希望你能享受朝着最终结果而努力的过程,这可以帮你获得一些新的知识。
如果你有任何问题或者不明白的地方,请在下方的评论栏中告诉我!
接下来的步骤
如果你想进一步提高或者延长时间线,这里有一些你可以做的小事情:
- 添加拖拽支持。我们可仅拖拽时间线区域,而不用通过点击时间按钮来导航。 对于这种行为,你能用本地拖放Api(遗憾的是在书写的时候不支持移动设备)或像Draggable.js外部库。
- 当我们调整浏览器窗口的大小时,改善时间线状态。例如,当我们调整窗口,按钮的可用及禁用状态应该不变。
- 以更易于管理的方式组织代码。或许,使用一种常见的JavaScript设计模式。