En este tutorial aprenderás a crear un timeline horizontal desplazable y arrastrable utilizando los plugins ScrollTrigger y Draggable de GSAP.
¿Qué es GSAP?
La Plataforma de Animación GreenSock (GSAP) es un popular conjunto de herramientas JavaScript para crear animaciones en la web. Cualquier cosa que veas en tu navegador web puede ser animada con GSAP. Tanto si quieres crear elegantes animaciones de interfaz de usuario como efectos dinámicos en aplicaciones web, juegos e historias interactivas, GSAP está a la altura de la tarea.
¿Cómo funciona? Simplemente escribes pequeños fragmentos de código JavaScript que definen cómo deben animarse los elementos y cuál debe ser el tiempo. La ventaja de animar con código es que una línea de código puede animar una cosa con la misma facilidad que puede animar 1.000 cosas. Con la animación basada en código es sorprendentemente fácil aleatorizar tus animaciones y hacer que respondan a la interacción del usuario.
GSAP ofrece la flexibilidad y el control que necesitan los profesionales, pero también es fácil de aprender para los principiantes, especialmente con la clase de Animación Web HTML5 con GreenSock de Noble Desktop.
¿Cómo usaremos GSAP para crear el timeline?
Los plugins ScrollTrigger y Draggable de la biblioteca de animación Greensock pueden ayudarnos a crear algunos efectos muy interesantes que responden a la interacción del usuario. En este tutorial veremos cómo utilizarlos juntos, para crear una línea de tiempo interactiva que se pueda desplazar y arrastrar.
Vamos a construir un timeline con los álbumes publicados por el grupo de rock Radiohead. El tema de nuestro timeline no importa realmente, lo principal es una serie de acontecimientos que suceden a lo largo de una serie de fechas, así que siéntete libre de elegir tu propio tema para hacerlo más personal para ti.
Tendremos una línea de tiempo a lo largo de la parte superior de nuestra página web mostrando nuestras fechas, y una serie de secciones de ancho completo donde vivirá nuestro contenido para cada una de esas fechas. Arrastrando la línea de tiempo horizontal se desplazará la página hasta el lugar apropiado del contenido, y del mismo modo, desplazando la página se actualizará nuestro timeline.
Además, al hacer clic en cualquiera de los enlaces de la línea de tiempo, el usuario podrá saltar directamente a la sección correspondiente. Esto significa que tenemos tres métodos diferentes para navegar por nuestra página, y todos ellos tienen que estar perfectamente sincronizados entre sí.

Vamos a recorrer los pasos para crear nuestro timeline. No dudes en saltar directamente a la demo final si quieres meterte de lleno en el código, o utiliza este Codepen de inicio, que incluye algunos estilos iniciales sencillos para que puedas concentrarte en el JS.
HTML
Empecemos con nuestro HTML. Como ésta va a ser la navegación principal de nuestra página, utilizaremos el elemento <nav>
. Dentro de éste, tenemos un marcador, que estilizaremos con CSS para indicar la posición en la línea de tiempo. También tenemos un <div>
con una clase de nav__track
, que será nuestro activador arrastrable. Alberga nuestra lista de enlaces de navegación.
<nav>
<!--Shows our position on the timeline-->
<div class="marker"></div>
<!--Draggable element-->
<div class="nav__track" data-draggable>
<ul class="nav__list">
<li>
<a href="#section_1" class="nav__link" data-link><span>1993</span></a>
</li>
<li>
<a href="#section_2" class="nav__link" data-link><span>1995</span></a>
</li>
<li>
<a href="#section_3" class="nav__link" data-link><span>1997</span></a>
</li>
<!--More list items go here-->
</ul>
</div>
</nav>
Debajo de nuestra navegación, tenemos el contenido principal de nuestra página, que incluye una serie de secciones. Daremos a cada una de ellas un id
que corresponda a uno de los enlaces de la navegación. De este modo, cuando un usuario haga clic en un enlace, se desplazará al lugar correspondiente del contenido, sin necesidad de JS.
También estableceremos a cada uno una propiedad personalizada correspondiente al índice de la sección. Esto es opcional, pero puede ser útil para el estilo. Por ahora no nos preocuparemos del contenido de nuestras secciones.
<main>
<section id="section_1" style="--i: 0"></section>
<section id="section_2" style="--i: 1"></section>
<section id="section_3" style="--i: 2"></section>
<!--More list sections go here-->
</main>
CSS
A continuación pasaremos a nuestro esquema básico. Daremos a cada sección un min-heigh
de 100vh
. También podemos darles un color de fondo, para que sea evidente cuando nos desplacemos por las secciones. Podemos utilizar la propiedad personalizada que establecimos en el último paso en combinación con la función de color hsl()
para dar a cada una un tono único:
section {
--h: calc(var(--i) * 30);
min-height: 100vh;
background-color: hsl(var(--h, 0) 75% 50%);
}
Colocaremos nuestro navegador en la parte superior de la página y le daremos una posición fija.
nav {
position: fixed;
top: 0;
left: 0;
width: 100%;
}
Mientras que la propia navegación será fija (para asegurar que permanece visible mientras el usuario se desplaza), la pista dentro de ella se podrá arrastrar. Tendrá que ser más ancha que la ventana gráfica, ya que queremos que el usuario pueda arrastrarla a lo largo de todo el recorrido. También necesita un poco de relleno, ya que necesitaremos que el usuario pueda arrastrar en el área después de que nuestros elementos hayan terminado, para que pueda mover la pista hasta el final.
Para asegurarnos de que nuestra pista tiene una anchura adecuada en todos los tamaños de la ventana gráfica, podemos utilizar la función max()
. Ésta devuelve el mayor de dos valores separados por comas. Con anchos de ventana reducidos, nuestra pista tendrá un mínimo de 200rem de ancho, lo que garantiza que nuestros elementos mantengan una distancia agradable entre sí. Con anchos de ventana mayores, la pista tendrá una anchura del 200%, lo que, teniendo en cuenta el relleno, significa que nuestros elementos se dispersarán uniformemente a lo largo de la anchura de la ventana cuando se posicionen con flexbox.
.nav__track {
position: relative;
min-width: max(200rem, 200%);
padding: 1.5rem max(100rem, 100%) 0 0;
height: 6rem;
}
.nav__list {
/* Remove default list styles */
list-style: none;
margin: 0;
padding: 0;
/* Position items horizontally */
display: flex;
justify-content: space-between;
}
También podemos dar estilo a nuestro marcador, que mostrará al usuario la posición actual en el timeline. Por ahora añadiremos un simple punto, que posicionaremos a 4rem de la izquierda. Si también establecemos una anchura de 4rem en nuestros elementos de navegación, esto debería centrar el primer elemento de navegación debajo del marcador a la izquierda de la ventana gráfica.
.marker {
position: fixed;
top: 1.75rem;
left: 4rem;
width: 1rem;
height: 1rem;
transform: translate3d(-50%, 0, 0);
background: blue;
border-radius: 100%;
z-index: 2000;
}
.nav__link {
position: relative;
display: block;
min-width: 8rem;
text-align: center;
}
Puede que quieras añadir algún estilo personalizado a la pista como he hecho en la demostración, pero esto debería ser suficiente para que pasemos al siguiente paso.
El JavaScript
Instalación de plugins
Utilizaremos el paquete principal de GSAP (Greensock) y sus plugins ScrollTrigger y Draggable. Hay muchas maneras de instalar GSAP – consulta esta página para ver las opciones. Si optas por la opción NPM, tendrás que importar los módulos en la parte superior del archivo JS, y registrar los plugins:
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
import Draggable from 'gsap/Draggable'
gsap.registerPlugin(ScrollTrigger, Draggable)
Crear la animación del timeline
Queremos que la pista se mueva horizontalmente cuando el usuario se desplace por la página o arrastre la propia línea de tiempo. Podríamos permitir al usuario arrastrar el marcador, pero esto no funcionaría bien si tuviéramos más elementos de navegación de los que caben horizontalmente en la ventana gráfica. Si mantenemos el marcador inmóvil mientras movemos la pista, nos da mucha más flexibilidad.
Lo primero que haremos será crear un timeline de animación con GSAP. Nuestra línea de tiempo es bastante sencilla: incluirá una sola interpolación para mover la pista hacia la izquierda, hasta que el último elemento esté justo debajo del marcador que hemos colocado antes. Necesitaremos utilizar la anchura del último elemento de la navegación en algunos otros lugares, así que crearemos una función que podamos llamar siempre que necesitemos este valor. Podemos utilizar la función de utilidad toArray
de GSAP para establecer una array de nuestros enlaces del nav como variable:
const navLinks = gsap.utils.toArray('[data-link]')
const lastItemWidth = () => navLinks[navLinks.length - 1].offsetWidth
Ahora podemos utilizarlo para calcular el valor x en nuestra interpolación:
const track = document.querySelector('[data-draggable]')
const tl = gsap.timeline()
.to(track, {
x: () => {
return ((track.offsetWidth * 0.5) - lastItemWidth()) * -1
},
ease: 'none' // important!
})
Easing
También vamos a eliminar el easing en nuestra interpolación del timeline. Esto es muy importante, ya que el movimiento estará ligado a la posición de desplazamiento, y la flexión causaría estragos en nuestros cálculos posteriores.
Creación de la instancia ScrollTrigger
Vamos a crear una instancia de ScrollTrigger, que activará la animación del timeline. Estableceremos el valor scrub
como 0
. Esto hará que nuestra animación se reproduzca al ritmo que el usuario se desplace. Un valor distinto de 0 crea un desfase entre la acción de desplazamiento y la animación, que puede funcionar bien en algunos casos, pero no nos servirá aquí.
const st = ScrollTrigger.create({
animation: tl,
scrub: 0
})
Nuestra línea de tiempo de la animación empezará a reproducirse en cuanto el usuario empiece a desplazarse desde la parte superior de la página, y terminará cuando la página se desplace hasta el final. Si necesitas algo diferente, tendrás que especificar también los valores de start
y end
en la instancia de ScrollTrigger. (Consulta la documentación de ScrollTrigger para más detalles).
Creación de la instancia Draggable
Ahora crearemos una instancia de Draggable. Pasaremos como primer argumento nuestro track (el elemento que queremos que sea arrastrable). En nuestras opciones (el segundo argumento) especificaremos <em>x</em>
para el tipo, ya que sólo queremos que se arrastre horizontalmente. También podemos establecer inertia
como true
. Esto es opcional, ya que requiere el plugin Inertia, un plugin premium para los miembros de Greensock (pero de uso gratuito en Codepen). Usar la inercia significa que cuando el usuario suelte el elemento después de arrastrarlo, éste se deslizará hasta detenerse de una forma más natural. No es estrictamente necesario para esta demo, pero prefiero el efecto.
const draggableInstance = Draggable.create(track, {
type: 'x',
inertia: true
})
A continuación queremos establecer los bounds
, de lo contrario existe el peligro de que el elemento sea arrastrado fuera de la pantalla. Estableceremos los valores mínimo y máximo a los que se puede arrastrar el elemento. No queremos que se arrastre más a la derecha de su posición inicial actual, así que estableceremos minX
como 0. El valor maxX
tendrá que ser el mismo valor que el utilizado en nuestra interpolación del timeline, así que ¿qué tal si creamos una función para ello?
const getDraggableWidth = () => {
return (track.offsetWidth * 0.5) - lastItemWidth()
}
const draggableInstance = Draggable.create(track, {
type: 'x',
inertia: true,
bounds: {
minX: 0,
maxX: getDraggableWidth() * -1
},
edgeResistance: 1 // Don’t allow any dragging beyond the bounds
})
Tendremos que establecer la Resistencia del borde, edgeResistance
en 1
, lo que impedirá cualquier arrastre más allá de nuestros límites especificados.
Cómo juntarlos
Ahora, ¡la parte técnica! Vamos a desplazar programáticamente la página cuando el usuario arrastre el elemento. Lo primero que hay que hacer es desactivar la instancia ScrollTrigger cuando el usuario empiece a arrastrar el tema, y volver a activarla cuando termine el arrastre. Para ello podemos utilizar las opciones onDragStart
y onDragEnd
de nuestra instancia Draggable:
const draggableInstance = Draggable.create(track, {
type: 'x',
inertia: true,
bounds: {
minX: 0,
maxX: getDraggableWidth() * -1
},
edgeResistance: 1,
onDragStart: () => st.disable(),
onDragEnd: () => st.enable()
})
Luego escribiremos una función que se llame al arrastrar. Obtendremos la posición de desplazamiento de nuestro elemento arrastrable (utilizando getBoundingClientRect()
). También necesitaremos saber la altura total desplazable de la página, que será la altura del documento menos la altura de la ventana gráfica. Vamos a crear una función para esto, para mantenerlo ordenado.
const getUseableHeight = () => document.documentElement.offsetHeight - window.innerHeight
Utilizaremos la función de utilidad mapRange()
de GSAP para encontrar la posición de desplazamiento relativa (consulta la documentación), y llamaremos al método scroll()
de la instancia ScrollTrigger para actualizar la posición de desplazamiento al arrastrar:
const draggableInstance = Draggable.create(track, {
type: 'x',
inertia: true,
bounds: {
minX: 0,
maxX: getDraggableWidth() * -1
},
edgeResistance: 1,
onDragStart: () => st.disable(),
onDragEnd: () => st.enable(),
onDrag: () => {
const left = track.getBoundingClientRect().left * -1
const width = getDraggableWidth()
const useableHeight = getUseableHeight()
const y = gsap.utils.mapRange(0, width, 0, useableHeight, left)
st.scroll(y)
}
})
Como estamos utilizando el plugin de Inercia, querremos llamar a la misma función durante la parte de «lanzamiento» de la interacción, es decir, después de que el usuario suelte el elemento, pero mientras conserva el impulso. Así que vamos a escribirla como una función independiente que podamos llamar para ambas cosas:
const updatePosition = () => {
const left = track.getBoundingClientRect().left * -1
const width = getDraggableWidth()
const useableHeight = getUseableHeight()
const y = gsap.utils.mapRange(0, width, 0, useableHeight, left)
st.scroll(y)
}
const draggableInstance = Draggable.create(track, {
type: 'x',
inertia: true,
bounds: {
minX: 0,
maxX: getDraggableWidth() * -1
},
edgeResistance: 1,
onDragStart: () => st.disable(),
onDragEnd: () => st.enable(),
onDrag: updatePosition,
onThrowUpdate: updatePosition
})
Ahora nuestra posición de desplazamiento y la pista del timeline deberían estar perfectamente sincronizadas cuando desplazamos la página o arrastramos la pista.
Navegar con un clic
También queremos que los usuarios puedan desplazarse hasta la sección deseada haciendo clic en cualquiera de los enlaces de la línea de tiempo. Podríamos hacerlo con JS, pero no es necesario: El CSS tiene una propiedad que permite el desplazamiento suave dentro de la página, y es compatible con la mayoría de los navegadores modernos (Safari es actualmente la excepción). Todo lo que necesitamos es esta línea de CSS, y nuestros usuarios se desplazarán suavemente hasta la sección deseada al hacer clic:
html {
scroll-behavior: smooth;
}
Accesibilidad
Es una buena práctica tener en cuenta a los usuarios que puedan ser sensibles al movimiento, así que incluyamos una consulta multimedia de prefers-reduced-motion
para garantizar que los usuarios que hayan especificado una preferencia a nivel de sistema por el movimiento reducido sean enviados directamente a la sección correspondiente:
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
Nuestra navegación presenta actualmente un problema para los usuarios que navegan con el teclado. Cuando nuestra navegación desborda la ventana gráfica, algunos de nuestros enlaces de navegación quedan ocultos a la vista, ya que están fuera de la pantalla. Cuando el usuario navega a través de los enlaces, necesitamos que esos enlaces se muestren a la vista. Podemos adjuntar un escuchador de eventos a nuestra pista para obtener la posición de desplazamiento de la sección correspondiente, y llamar a scroll()
en la instancia de ScrollTrigger, lo que tendrá el efecto de mover también el timeline (manteniendo ambas sincronizadas):
track.addEventListener('keyup', (e) => {
const id = e.target.getAttribute('href')
/* Return if no section href or the user isn’t using the tab key */
if (!id || e.key !== 'Tab') return
const section = document.quersaySelector(id)
/* Get the scroll position of the section */
const y = section.getBoundingClientRect().top + window.scrollY
/* Use the ScrollTrigger to scroll the window */
st.scroll(y)
})
Llamar a scroll()
también respeta las preferencias de movimiento de nuestros usuarios: los usuarios con una preferencia de movimiento reducido serán saltados a la sección en lugar de desplazarse suavemente.
Animación de las secciones
Nuestro timeline debería funcionar bastante bien ahora, pero todavía no tenemos ningún contenido. Añadamos un título y una imagen para cada sección, y animémoslos cuando aparezcan. Aquí tienes un ejemplo del HTML para una sección, que podemos repetir para la otra (ajustando el contenido según sea necesario):
<main>
<section id="section_1" style="--i: 0">
<div class="container">
<h2 class="section__heading">
<span>1993</span>
<span>Pablo Honey</span>
</h2>
<div class="section__image">
<img src="https://assets.codepen.io/85648/radiohead_pablo-honey.jpg" width="1200" height="1200" />
</div>
</div>
</section>
<!--more sections-->
</main>
Estoy utilizando display: grid
para posicionar el encabezado y la imagen en una disposición agradable – pero siéntete libre de posicionarlos como quieras. En esta parte sólo nos concentraremos en el JS.
Crear el timeline con GSAP
Crearemos una función llamada initSectionAnimation()
. Lo primero que haremos es devolver antes si nuestros usuarios prefieren el movimiento reducido. Podemos utilizar una consulta de medios prefers-reduced-motion
utilizando el método matchMedia
:
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
const initSectionAnimation = () => {
/* Do nothing if user prefers reduced motion */
if (prefersReducedMotion.matches) return
}
initSectionAnimation()
A continuación, estableceremos el estado de inicio de la animación para cada sección:
const initSectionAnimation = () => {
/* Do nothing if user prefers reduced motion */
if (prefersReducedMotion.matches) return
sections.forEach((section, index) => {
const heading = section.querySelector('h2')
const image = section.querySelector('.section__image')
/* Set animation start state */
gsap.set(heading, {
opacity: 0,
y: 50
})
gsap.set(image, {
opacity: 0,
rotateY: 15
})
}
}
A continuación, crearemos un nuevo timeline para cada sección, añadiendo ScrollTrigger a la propia línea de tiempo para controlar cuándo se reproduce la animación. Esta vez podemos hacerlo directamente, en lugar de crear una instancia separada de ScrollTrigger, ya que no necesitamos que esta línea de tiempo esté conectada a un elemento arrastrable. (Todo este código está dentro del bucle forEach
.) Añadiremos algunos tweens al timeline para animar el encabezamiento y la imagen a la vista.
/* In the `forEach` loop: */
/* Create the section timeline */
const sectionTl = gsap.timeline({
scrollTrigger: {
trigger: section,
start: () => 'top center',
end: () => `+=${window.innerHeight}`,
toggleActions: 'play reverse play reverse'
}
})
/* Add tweens to the timeline */
sectionTl.to(image, {
opacity: 1,
rotateY: -5,
duration: 6,
ease: 'elastic'
})
.to(heading, {
opacity: 1,
y: 0,
duration: 2
}, 0.5) // the heading tween will play 0.5 seconds from the start
Por defecto, nuestras interpolaciones se reproducirán una tras otra. Pero estoy utilizando el parámetro de posición para especificar que la interpolación de encabezamiento se reproduzca a 0,5 segundos del comienzo del timeline, de modo que nuestras animaciones se superpongan.
Aquí tienes la demo completa en acción:
Y nada más

Si llegaste hasta aquí sin problemas, enhorabuena, ya tienes un Timeline completamente funcional y con tecnología GSAP.
Si tienes algún problema ya sabes que tienes a tu disposición, aquí abajo, el recuadro de comentarios donde podrás enviarnos tus dudas.
Deja una respuesta