Building a Scrollable and Draggable Timeline with GSAP
We’re going to build a timeline showing albums released by the rock band Radiohead. The subject of our timeline doesn’t really matter — the main thing is a series of events that happen over a number of dates — so feel free to pick your own subject matter to make it more personal to you!
We’ll have a timeline along the top of our webpage showing our dates, and a number of full-width sections where our content for each of those dates will live. Dragging the horizontal timeline should scroll the page to the appropriate place in the content, and likewise scrolling the page will cause our timeline to update. Additionally, clicking any of the links in the timeline will allow the user to jump straight to the relevant section. This means we have three different methods for navigating our page — and they all have to sync perfectly with one another.
We’re going to walk through the steps for creating our timeline. Feel free to jump straight to the final demo if you want to get stuck into the code, or use this starter Codepen, which includes some simple initial styles so you can concentrate on the JS.
Markup
Let’s start with our HTML. As this is going to be our main page navigation, we’ll use the <nav>
element. Inside this, we have a marker, which we’ll style with CSS to indicate the position on the timeline. We also have a <div>
with a class of nav__track
, which will be our draggable trigger. It houses our list of navigation links.
<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>
Below our nav, we have the main content of our page, which includes a number of sections. We’ll give each one an id
that corresponds to one of the links in the navigation. That way, when a user clicks a link they’ll be scrolled to the relevant place in the content — no JS required.
We’ll also set each one a custom property corresponding to the section’s index. This is optional, but can be useful for styling. We won’t worry about the content of our sections for now.
<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
Next we’ll move onto our basic layout. We’ll give each section a min-height
of 100vh
. We can also give them a background color, to make it obvious when we’re scrolling through the sections. We can use the custom property we set in the last step in combination with the hsl()
color function to give each one a unique hue:
section {
--h: calc(var(--i) * 30);
min-height: 100vh;
background-color: hsl(var(--h, 0) 75% 50%);
}
We’ll position our nav along the top of the page and give it a fixed position.
nav {
position: fixed;
top: 0;
left: 0;
width: 100%;
}
While the nav itself will be fixed (to ensure it remains visible as the user scrolls), the track inside it will be draggable. This will need to be wider than the viewport, as we want the user to be able to drag it all the way along. It also needs some padding, as we’ll need the user to be able to drag on the area after our items have ended, so that they can move the track all the way along. To ensure our track has a suitable width at all viewport sizes, we can use the max()
function. This returns the largest of two comma-separated values. At narrow viewport widths our track will be a minimum of 200rem wide, ensuring that our items retain a pleasing distance from one another. At larger viewport widths the track will be 200% wide which, accounting for the padding, means our items will be dispersed evenly along the width of the viewport when position them with 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;
}
We can also style our marker, which will show the user the current position on the timeline. For now we’ll add a simple dot, which we’ll position 4rem from the left. If we also set a width of 4rem on our navigation items, this should center the first navigation item below the marker on the left of the viewport.
.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;
}
You might want to add some custom styling to the track like I’ve done in the demo, but this should be enough for us to move onto the next step.
The JavaScript
Installing plugins
We’ll be using the GSAP (Greensock) core package and its ScrollTrigger and Draggable plugins. There are many ways to install GSAP — check out this page for options. If you go for the NPM option, you’ll’ll need to import the modules at the top of the JS file, and register the plugins:
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
import Draggable from 'gsap/Draggable'
gsap.registerPlugin(ScrollTrigger, Draggable)
Creating the animation timeline
We want the track to move horizontally when the user scrolls the page or drags the timeline itself. We could allow the user to drag the marker instead, but this wouldn’t work well if we had more navigation items than would fit horizontally in the viewport. If we keep the marker stationary while moving the track, it gives us a lot more flexibility.
The first thing we’ll do is create an animation timeline with GSAP. Our timeline is quite simple: it will include just a single tween to move the track to the left, until the last item is just below the marker we positioned earlier. We’ll need to use the width of the last nav item in some other places, so we’ll create a function we can call whenever we need this value. We can use GSAP’s toArray
utility function to set an array of our nav links as a variable:
const navLinks = gsap.utils.toArray('[data-link]')
const lastItemWidth = () => navLinks[navLinks.length - 1].offsetWidth
Now we can use that to calculate the x value in our tween:
const track = document.querySelector('[data-draggable]')
const tl = gsap.timeline()
.to(track, {
x: () => {
return ((track.offsetWidth * 0.5) - lastItemWidth()) * -1
},
ease: 'none' // important!
})
Easing
We’re also removing the easing on our timeline tween. This is very important, as the movement will be tied to the scroll position, and easing would play havoc with our calculations later on!
Creating the ScrollTrigger instance
We’re going to create a ScrollTrigger instance, which will trigger the timeline animation. We’ll set the scrub
value as 0
. This will cause our animation to play at the rate the user scrolls. A value other than 0
creates a lag between the scroll action and the animation, which can work nicely in some instances, but won’t serve us well here.
const st = ScrollTrigger.create({
animation: tl,
scrub: 0
})
Our animation timeline will start playing as soon as the user starts scrolling from the top of the page, and end when the page is scrolled all the way to the bottom. If you need anything different, you’ll need to specify start
and end
values on the ScrollTrigger instance too. (See the ScrollTrigger documentation for more details).
Creating the Draggable instance
Now we’ll create a Draggable instance. We’ll pass in our track as the first argument (the element we want to make draggable). In our options (the second argument) we’ll specify <em>x</em>
for the type, as we only want it to be dragged horizontally. We can also set inertia
to true
. This is optional, as it requires the Inertia plugin, a premium plugin for Greensock members (but free to use on Codepen). Using Inertia mean that when the user lets go after dragging the element, it will glide to a stop in a more naturalistic way. It’s not strictly necessary for this demo, but I prefer the effect.
const draggableInstance = Draggable.create(track, {
type: 'x',
inertia: true
})
Next we want to set the bounds
, otherwise there’s a danger the element could be dragged right off the screen. We’ll set the minimum and maximum values the element can be dragged. We don’t want it to be dragged any further to the right than its current start position, so we’ll set minX
as 0. The maxX
value will in fact need to be the same value as used in our timeline tween — so how about we make a function for that:
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
})
We’ll need to set edgeResistance
to 1
, which will prevent any dragging at all beyond our specified bounds.
Putting them together
Now, for the technical part! We’re going to programmatically scroll the page when the user drags the element. The first thing to do is to disable the ScrollTrigger instance when the user starts dragging the track, and re-enable it when the drag ends. We can use the onDragStart
and onDragEnd
options on our Draggable instance to do that:
const draggableInstance = Draggable.create(track, {
type: 'x',
inertia: true,
bounds: {
minX: 0,
maxX: getDraggableWidth() * -1
},
edgeResistance: 1,
onDragStart: () => st.disable(),
onDragEnd: () => st.enable()
})
Then we’ll write a function that gets called on drag. We’ll get the offset position of our draggable element (using getBoundingClientRect()
). We’ll also need to know the total scrollable height of the page, which will be the document height minus the viewport height. Let’s create a function for this, to keep it tidy.
const getUseableHeight = () => document.documentElement.offsetHeight - window.innerHeight
We’ll use GSAP’s mapRange()
utility function to find the relative scroll position (see the documentation), and call the scroll()
method on the ScrollTrigger instance to update the scroll position on drag:
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)
}
})
As we’re using the Inertia plugin, we’ll want to call the same function during the “throw” part of the interaction — after the user lets go of the element, but while it retains momentum. So let’s write it as a separate function we can call for both:
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
})
Now our scroll position and timeline track should be perfectly in sync when we scroll the page or drag the track.
Navigating on click
We also want users to be able to scroll to the desired section by clicking on any of the timeline links. We could do this with JS, but we don’t necessarily need to: CSS has a property that allows smooth scrolling within the page, and it’s supported in most modern browsers (Safari is currently the exception). All we need is this one line of CSS, and our users will be scrolled smoothly to the desired section on click:
html {
scroll-behavior: smooth;
}
Accessibility
It’s good practice to consider users who may be sensitive to motion, so let’s include a prefers-reduced-motion
media query to ensure that users who have specified a system-level preference for reduced motion will be jumped straight to the relevant section instead:
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
Our navigation currently presents a problem for users who navigate using a keyboard. When our nav overflows the viewport, some of our nav links are hidden from view, as they are offscreen. When the user tabs through the links, we need those links to be brought into view. We can attach an event listener to our track to get the scroll position of the corresponding section, and call scroll()
on the ScrollTrigger instance, which will have the effect of moving the timeline too (keeping them both in sync):
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.querySelector(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)
})
Calling scroll()
also respects our users’ motion preferences — users with a reduced-motion preference will be jumped to the section instead of smoothly scrolled.
See the Pen
GSAP Draggable and ScrollTrigger timeline [Simple 1] by Michelle Barker (@michellebarker)
on CodePen.0
Animating the sections
Our timeline should work pretty well now, but we don’t yet have any content. Let’s add a heading and image for each section, and animate them when the come into view. Here’s an example of the HTML for one section, which we can repeat for the other (adjusting the content as needed):
<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>
I’m using display: grid
to position the heading and image in a pleasing arrangement — but feel free to position them as you like. We’ll just concentrate on the JS for this part.
Creating the timelines with GSAP
We’ll create a function called initSectionAnimation()
. The first thing we’ll do is return early if our users prefer reduced motion. We can used a prefers-reduced-motion
media query using the matchMedia
method:
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
const initSectionAnimation = () => {
/* Do nothing if user prefers reduced motion */
if (prefersReducedMotion.matches) return
}
initSectionAnimation()
Next we’ll set our animation start state for each section:
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
})
}
}
Then we’ll create a new timeline for each section, adding ScrollTrigger to the timeline itself to control when the animation is played. We can do this directly this time, rather than creating a separate ScrollTrigger instance, as we don’t need this timeline to be connected to a draggable element. (This code is all within the forEach
loop.) We’ll add some tweens to the timeline to animate the heading and image into view.
/* 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
By default our tweens will play one after the other. But I’m using the position parameter to specify that the heading tween should play 0.5 seconds from the beginning of the timeline, so our animations overlap.
Here’s the complete demo in action:
See the Pen
GSAP Draggable and ScrollTrigger timeline [FINAL] by Michelle Barker (@michellebarker)
on CodePen.0