Elaborando una Línea de Tiempo Horizontal Con CSS y JavaScript
() translation by (you can also view the original English article)
En un post previo, te mostré cómo crear una línea de tiempo vertical responsiva desde cero. Hoy, cubriré el proceso de crear la línea de tiempo horizontal asociada.
Como es habitual, para obtener una idea inicial de lo que estaremos creando, ve el demo relacionado en CodePen (consulta la versión más grande para una mejor experiencia):
Tenemos mucho que cubrir, así que ¡comencemos!
1. Marcado HTML
El marcado es idéntico al marcado que definimos para la línea de tiempo vertical, aparte de tres pequeñas cosas:
- Usamos una lista ordenada en lugar de una lista desordenada pues es más semánticamente correcto.
- Hay un elemento extra en la lista (el último) que está vacío. En una próxima sección, discutiremos la razón.
- Hay un elemento extra (es decir
.arrows
) que es responsable de la navegación de la línea de tiempo.
Aquí está el marcado requerido:
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>
|
El estado inicial de la línea de tiempo se ve así:
2. Añadiendo Estilos CSS Iniciales
Después de algunos estilos de fuente básicos, estilos de color, etc. que he omitido aquí por simpleza, especificamos algunas reglas CSS estructurales:
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 |
}
|
Lo más importante aquí, notarás dos cosas:
- Asignamos padding grande inferior y superior a la lista. De nuevo, explicaremos por qué ello ocurre en la próxima sección.
- Como notarás en el siguiente demo, en éste punto no podemos ver todos los elementos de la lista porque la lista tiene
width:100vw
y su padre tieneoverlfox-x:hidden
. Ésto efectivamente "enmascara" los elementos de la lista. Sin embargo, gracias a la navegación de la línea de tiempo, podremos navegar a través de los elementos más adelante.
Con éstas reglas en su lugar, aquí está el estado actual de la línea de tiempo (sin ningún contenido real, para mantener las cosas claras):
3. Estilos de Elementos de la Línea de Tiempo
En éste punto aplicaremos estilo a los elementos div
(los llamaremos "elementos de línea de tiempo" de aquí en adelante) que son parte de los elementos de la lista así como sus pseudo-elementos ::before
.
Adicionalmente, usaremos las pseudo-clases CSS :nth-child(odd)
y :nth-child(even)
para diferenciar los estilos para los divs pares e impares.
Aquí están los estilos comunes para los elementos de la línea de tiempo:
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 |
}
|
Luego algunos estilos para los impares:
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 |
}
|
Y finalmente algunos estilos para los pares:
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 |
}
|
Aquí está el nuevo estado de la línea de tiempo, de nuevo con contenido añadido:
Como probablemente lo has notado, los elementos de la línea de tiempo estás posicionados absolutamente. Ello significa que son removidos del flujo normal del documento. Con ello en mente, para asegurar que toda la línea de tiempo aparezca, tenemos que establecer los valores grandes de padding superior e inferior para la lista. Si no aplicamos ningún padding, la línea de tiempo será recortada:



4. Estilos de Navegación de la Línea de Tiempo
Ahora es momento de aplicar estilo a los botones de navegación. Recuerda que por defecto deshabilitamos la flecha previa y le damos la clase de disabled
.
Aquí están los estilos CSS asociados:
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 |
}
|
Las reglas de arriba nos dan ésta línea de tiempo:
5. Agregando Interactividad
La estructura básica de la línea de tiempo está lista. ¡Agreguemos algo de interactividad a ella!
Variables
Primero lo primero, definimos un conjunto de variables que utilizaremos más adelante.
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"; |
Inicializando las Cosas
Cuando todos los recursos de la página están listos, la función init
es llamada.
1 |
window.addEventListener("load", init); |
Ésta función desencadena cuatro sub-funciones:
1 |
function init() { |
2 |
setEqualHeights(elH); |
3 |
animateTl(xScrolling, arrows, timeline); |
4 |
setSwipeFn(timeline, arrowPrev, arrowNext); |
5 |
setKeyboardFn(arrowPrev, arrowNext); |
6 |
}
|
Como veremos en un momento, cada una de éstas funciones realiza una cierta tarea.
Elementos de la Línea de Tiempo de Igual Altura
Si regresas al último demo, notarás que los elementos de la línea de tiempo no tienen alturas iguales. Ésto no afecta la funcionalidad principal de nuestra línea de tiempo, pero podrías preferirlo si todos los elementos tuvieran la misma altura. Para lograr ésto, podemos darles una altura fija vía CSS (solución fácil) o una altura dinámica que corresponde a la altura del elemento más alto vía JavaScript.
La segunda opción es más flexible y estable, así que aquí está una función que implementa éste comportamiento:
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 |
}
|
Ésta función toma la altura del elemento más alto de la línea de tiempo y la establece como la altura predeterminada para todos los elementos.
Aquí está cómo se ve el demo:
6. Animando la Línea de Tiempo
Ahora vamos a enfocarnos en la animación de la línea de tiempo. Crearemos la función que implementa éste comportamiento paso-a-paso.
Primero, registramos un click event listener para los botones de la línea de tiempo:
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 |
}
|
Cada vez que se hace click en un botón, verificamos el estado deshabilitado de los botones de la línea de tiempo y si no están deshabilitados, los deshabilitamos. Ésto asegura que en ambos botones se hará click una vez hasta que finalice la animación.
Así que, en términos de código, el click handler inicialmente contiene éstas líneas:
1 |
if (!arrowPrev.disabled) { |
2 |
arrowPrev.disabled = true; |
3 |
}
|
4 |
|
5 |
if (!arrowNext.disabled) { |
6 |
arrowNext.disabled = true; |
7 |
}
|
Los próximos pasos son como sigue:
- Verificamos si es la primera vez que hemos hecho click en un botón. De nuevo, ten en mente que el botón previo es deshabilitado por defecto, así que el único botón que puede aplicarse un click inicialmente es el siguiente.
- Si en efecto es la primera vez, usamos la propiedad
transform
para mover la línea de tiempo 280px a la derecha. El valor de la variablexScrolling
determina la cantidad de movimiento. - En caso contrario, si ya hemos hecho click en un botón, tomamos el valor actual de
transform
de la línea de tiempo y agregamos o removemos ese valor, la cantidad deseada de movimiento (es decir 280px). Así que, mientras hacemos click en el botón previo, el valor de la propiedadtransform
decrece y la línea de tiempo es movida de izquierda a derecha. Sin embargo, cuando el botón siguiente recibe un click, el valor de la propiedadtransform
se incrementa y la línea de tiempo es movida de derecha a izquierda.
El código que implementa ésta funcionalidad es como sigue:
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 |
}
|
¡Magnífico trabajo! Acabamos de definir una manera de animar la línea de tiempo. El próximo desafío es averiguar cuando ésta animación debería parar. Aquí está nuestro planteamiento:
- Cuando el primer elemento de la línea de tiempo llega a ser completamente visible, significa que ya hemos alcanzado el inicio de la línea de tiempo, y así deshabilitamos el botón previo. También aseguramos que el botón siguiente esté habilitado.
- Cuando el último elemento llega a ser completamente visible, significa que ya hemos llegado al final de la línea de tiempo, y así deshabilitamos el botón siguiente. También, por lo tanto, aseguramos que el botón previo está habilitado.
Recuerda que el último elemento es uno vacío con anchura igual a la anchura de los elementos de la línea de tiempo (es decir 280px). Le damos éste valor (o uno más alto) porque queremos asegurar que el último elemento de la línea de tiempo será visible antes de deshabilitar el botón siguiente.
Para detectar si los elementos objetivo están o no totalmente visibles en la ventana gráfica actual, aprovecharemos el mismo código usado pra la línea de tiempo vertical. El código requerido que viene de ésta consulta de Stack Overflow es como sigue:
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 |
}
|
Más allá de la función de arriba, definimos otro helper:
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 |
}
|
Ésta función añade o remueve la clase disabled
de un elemento basado en el valor del parámetro flag
. Además, puede cambiar el estado deshabilitado para éste elemento.
Dado lo que hemos descrito arriba, aquí está el código que definios para verificar si la animación debería o no parar:
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 |
}
|
Nota que hay un 1.1 segundo de retraso antes de ejecutar éste código. ¿Por qué ocurre ésto?
Si regresamos a nuestro CSS, veremos ésta regla:
1 |
.timeline ol { |
2 |
transition: all 1s; |
3 |
}
|
Así que, la animación de la línea de tiempo necesita 1 segundo para completarse. Mientras se completa, esperamos 100 milisegundos y luego, ejecutamos nuestras verificaciones.
Aquí está la línea de tiempo con animaciones:
7. Agregando Soporte de Deslizamiento
Hasta ahora, la línea de tiempo no responde a eventos touch. Aunque sería agradable si pudiéramos agregar ésta funcionalidad. Para lograrlo, podemos escribir nuestra implementación JavaScript o usar uno de las librerías relacionadas (por ejemplo Hammer.js, TouchSwipe.js) que existen.
Para nuestro demo, mantendremos ésto sencilo y usemos Hammer.js, así que primero, incluímos ésta librería en nuestro pen:



Luego declaramos la función asociada:
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 |
}
|
Dentro de la función de arriba, hacemos lo siguiente:
- Crea una instancia de Hammer.
- Registra handlers para los eventos
swipeleft
yswiperight
. - Cuando nos deslizamos sobre la línea de tiempo en la dirección izquierda, desencadenamos un click en el botón siguiente, y así la línea de tiempo es animada de derecha a izquierda.
- Cuando nos deslizamos por la línea de tiempo en la dirección derecha, desencadenamos un click en el botón previo, y así la línea de tiempo es animada de izquierda a derecha.
La línea de tiempo con soporte de deslizamiento:
Agregando Navegación de Teclado
Mejoremos más la experiencia de usuario al brindar soporte para navegación por teclado. Nuestras metas:
- Cuando la tecla flecha izquierda o derecha es presionada, el documento debería ser desplazado a la posición superior de la línea de tiempo (si otra sección de página es actualmente visible). Ésto asegura que toda la línea de tiempo será visible.
- Específicamente, cuando la tecla flecha izquierda es presionada, la línea de tiempo debería ser animada de izquierda a derecha.
- De la misma manera, cuando la tecla flecha derecha es presionada, la línea de tiempo debería ser animada de derecha a izquierda.
La función asociada es la siguiente:
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 |
}
|
La línea de tiempo con soporte de teclado:
8. Convirtiéndose en Responsiva
¡Casi terminamos! Al último pero no menos importante, hagamos responsiva la línea de tiempo. Cuando la ventana gráfica es inferior a 600 px, debería tener el siguiente maquetado apilado:



Como estamos usando un planteamiento de primero-escritorio, aquí están las reglas CSS que tenemos que sobreescribir:
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 |
}
|
Nota: Para dos de las reglas de arriba, tuvimos que usar la regla !important
para sobreescribir los estilos inline relacionados aplicados a través de JavaScript.
El estado final de nuestra línea de tiempo:
Soporte de Navegadores
El demo funcona bien en todos los navegadores modernos y dispositivos. También, como posiblemente lo has notado, usamos Babel para compilar nuestro código ES6 a ES5.
El único pequeño problema que encontré mientras lo probaba es el cambio de renderizado de texto que ocurre cuando la línea de tiempo está siendo animada. Aunque intenté varios planteamientos propuestos en diferentes consultas en Stack Overflow, no encontré una solución sencilla para todos los sistemas operativos y navegadores. Así que, ten en mente que podrías ver pequeños problemas de renderizado de fuente cuando la línea de tiempo está siendo animada.
Conclusión
En éste tutorial sustancioso, comenzamos con una simple lista ordenada y creamos una línea de tiempo horizontal responsiva. Sin duda, cubrimos muchas cosas interesantes, pero espero que hayas disfrutado trabajando para el resultado final y que te haya ayudado a adquirir algún nuevo conocimiento.
Si tienes alguna pregunta o si hay cualquier cosa que no entendiste, ¡házmelo saber en los comentarios abajo!
Próximos Pasos
Si quieres mejorar más o extender ésta línea de tiempo, aquí están unas cuantas cosas que puedes hacer:
- Agregar soporte para arrastrar. En lugar de hacer click en los botones de la línea de tiempo para navegar, podríamos solo arrastrar el área de la línea de tiempo. Para éste comportamiento, podrías usar la nativa Api Drag and Drop (que desafortunadamente no soporta dispositivos móviles al momento de escribir éste artículo) o una librería externa como Draggable.js.
- Mejora el comportamiento de la línea de tiempo mientras redimensionamos la ventana del navegador. Por ejemplo, cuando redimensionamos la ventana, los botones deberían estar habilitados o deshabilitados consecuentemente.
- Organiza el código en una manera más manejable. Quizá, usar un común Patrón de Diseño de JavaScript.