Date:

Share:

Building a Scrollable and Draggable Timeline with GSAP

Related Articles

From our sponsor: B25600467Try Mailchimp today.

We’re going to build a timeline featuring albums released by rock band Radiohead. The theme of our timeline does not really matter – the main thing is a series of events that take place over a number of dates – so feel free to choose your theme to make it more personal for you!

We’ll have a timeline at the top of our webpage showing our dates, and a number of full-width sections where our content for each of those dates will appear. Dragging the horizontal timeline should scroll the page to the appropriate place in the content, as well as scrolling through the page will update our timeline. In addition, clicking on any of the links in the timeline will allow the user to jump straight to the relevant section. That means we have three different methods of navigating our page – and they all need to be perfectly synchronized with each other.

Three steps showing the horizontal timeline move from right to left while the page itself scrolls vertically

We will go through the steps for creating our own timeline. Feel free to jump straight to The final demo If you want to get stuck in code, or use This novice hijacker, Which includes some simple initial styles so you can concentrate on JS.

Mark

Let’s start with our HTML. Since this is going to be the navigation on our main page, we will use <nav> element. Inside it, we have a cursor, which we designed with CSS to indicate the position on the timeline. We also have a <div> With a rate of nav__track, Which will be our towable trigger. It contains 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 navigator, we have the main content of our page, which includes a number of sections. We are given to everyone an id Which corresponds to one of the links in the navigation. This way, when a user clicks on a link he will scroll to the relevant place in the content – without the need for JS.

We will also define for each one a custom attribute that matches the section index. It is optional, but can be useful for styling. We will not worry about the content of our sections for the time being.

<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 will move on to our basic layout. Given to each section a min-height of 100vh. We can also give them a background color, so that it is clear when we scroll through the sections. We can use the custom attribute we defined in the last step in combination with 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 will place our navigation at the top of the page and give it a permanent position.

nav 
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;

While the navigator itself will be repaired (to ensure it remains visible when the user scrolls), the route within it will be towable. It will need to be wider from the viewpoint, as we want the user to be able to drag it all the way. It also needs some padding, as we will need the user to be able to drag on the area be late Our items are finished so they can move the track all the way. To ensure our track has a suitable width in all display port sizes, we can use max() function. This returns the larger values ​​of the two separated by commas. In the width of a narrow display port our route will be a minimum of 200 rm wide, which ensures that our items will maintain a pleasant distance from each other. With a larger display port width, the track will be 200% wide, which is responsible for the padding, which means our items will be evenly distributed across the display port width when placed with a 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 cursor, which will show the user the current position on the timeline. For now we will add a simple dot, which is placed 4rem on the left. If we also set a width of 4rem on our navigation items, it should center the first navigation item below the mark on the left side of the display port.

.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 may want to add some custom styling to the track like I did in the demo, but that should be enough for us to move on to the next step.

JavaScript

Installing plugins

We will use the core package of GSAP (Greensock) and its ScrollTrigger and Draggable plugins. There are many ways to install GSAP – check This page For options. If you select NPM, you will need to import the modules at the top of the JS file, and list the extensions:

import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
import Draggable from 'gsap/Draggable'

gsap.registerPlugin(ScrollTrigger, Draggable)

Creating the timeline of the animation

We want the track to move horizontally when the user scrolls the page or drags the timeline itself. we could have Allow the user to drag the cursor instead, but it would not work well if we had more navigation items than would fit horizontally at the viewpoint. If we leave the cursor stationary while moving the trajectory, it gives us much more flexibility.

The first thing to do is create an animation Timeline With GSAP. Our timeline is pretty simple: it will only include a single twin to move the track to the left, until the last item is just below the marker we placed earlier. We’ll have to use the width of the last navigation item elsewhere, so we’ll create a function that we can call whenever we need that value. We can use GSAP toArray Service function To set an array of our navigation links as a variable:

const navLinks = gsap.utils.toArray('[data-link]')

const lastItemWidth = () => navLinks[navLinks.length - 1].offsetWidth

Now we can use it 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 also remove the relief on our timeline. This is very important, as the traffic will be tied to the scroll bar, and the ease will hurt our calculations later!

Create a ScrollTrigger instance

We’re going to create a ScrollTrigger instance, which will activate the timeline animation. We will define the scrub Value as 0. This will make our animation play at the user’s scrolling pace. Value other than 0 Creates a lag between scrolling and animation, which can work fine in some cases, but will not serve us here.

const st = ScrollTrigger.create(
	animation: tl,
	scrub: 0
)

Our animation timeline will start running as soon as the user starts scrolling from the top of the page, and will end when the page scrolls to the end. If you need something else, you will need to specify start and end Values ​​also in the ScrollTrigger instance. (look at ScrollTrigger documentation for further details).

Create the drag-and-drop show

We will now create a show that can be dragged. We will move on in our trajectory as the first argument (the element we want to become a drag). In our options (the second argument) we will detail <em>x</em> For the type, because we only want it to be dragged horizontally. We can also set inertia To true. It’s optional, because it requires the Inertial supplement, A premium plugin for Greensock members (but free to use in Codepen). Using inertia means that when the user lets go after dragging the element, it will slide until it stops more naturally. It’s not 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 is a danger that the component will be dragged directly from the screen. We will set the minimum and maximum values ​​that the element can be dragged. We do not want it to be dragged further to the right of its current starting position, so we will define minX as 0. God maxX The value will actually have to be the same as the value used in our timeline twin – so how about we create a function for this:

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 have to set up edgeResistance To 1, Which will prevent any dragging beyond the specified limits.

Connects them together

Now, for the technical part! We are going to scroll the page programmatically when the user drags the element. The first thing to do is disable the ScrollTrigger instance when the user starts dragging the track, and restart it when the drag is complete. We can use onDragStart and onDragEnd Options in our towing show to do this:

const draggableInstance = Draggable.create(track, 
	type: 'x',
	inertia: true,
	bounds: 
		minX: 0,
		maxX: getDraggableWidth() * -1
	,
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable()
)

We will then write a function called drag. We will get the offset position of our towable element (using getBoundingClientRect()). We will also need to know the total height that can be scrolled of the page, so that the height of the document is less than the height of the display port. Let’s create a function for it, to keep it neat.

const getUseableHeight = () => document.documentElement.offsetHeight - window.innerHeight

We will use GSAP mapRange() Auxiliary function for finding the relative scroll position (See documentation), And called scroll() Method in the ScrollTrigger instance to update the scrolling position by dragging:

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)
  
)

When we use the Inertia plugin, we want to call the same function during the “throw” part of the interaction – after the user lets go of the element, but while maintaining the momentum. So let’s write this as a separate function that we can call 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 trajectory should be perfectly synchronized as we scroll through the page or Drag the route.

Click navigation

We also want users to be able to scroll to the desired section by clicking on any of the timeline links. we could have Do this with JS, but we do not necessarily have to: CSS has a feature that allows smooth scrolling within the page, and is supported by most modern browsers (Safari is currently the exception). All we need is one line of CSS, and our users will seamlessly scroll to the desired section by clicking:

html 
	scroll-behavior: smooth;

accessibility

It’s good practice to consider users who may be traffic sensitive, so let’s include a prefers-reduced-motion Media query to ensure that users who have specified a system-level preference for reduced traffic will jump straight to the relevant section instead:

@media (prefers-reduced-motion: no-preference) 
	html 
		scroll-behavior: smooth;
	

Our navigation now presents a problem for users who navigate using a keyboard. When our navigation floods the viewport, some of our navigation links are hidden from view because they are off-screen. When the user passes tabs through the links, we need to display those links. We can attach an event listener to our track to get the scrolling position of the appropriate segment, and call scroll() In the ScrollTrigger instance, which will have the effect of moving the timeline as well (keep both in sync):

track.addEventListener('keyup', (e) =>  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)
)

destiny scroll() Also respects the traffic preferences of our users – users with reduced traffic preference will be surrounded by a section instead of a smooth scroll.

See the pen
GSAP Draggable and ScrollTrigger timeline [Simple 1]
By Michelle Barker (@michellebarker)
On CodePen.0

Animation of the sections

Our timeline is supposed to work pretty well right now, but we still have no content. Let’s add a title and image for each part, and we’ll make an animation when they reach the display. Here is an example HTML for one part, which we can repeat 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 use display: grid Place the title and image in a nice arrangement – but feel free to place them as you wish. We’ll just concentrate on JS for this part.

Create timelines with GSAP

We will create a function called initSectionAnimation(). The first thing to do is to return early if our users prefer reduced traffic. We can use prefers-reduced-motion Media query using matchMedia method:

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')

const initSectionAnimation = () => 
	/* Do nothing if user prefers reduced motion */
	if (prefersReducedMotion.matches) return


initSectionAnimation()

In the next step we will define the start mode of the animation for each part:

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
		)
	

We will then create a new timeline for each section, and add the ScrollTrigger to the timeline itself to control when the animation is played. We can do this directly this time, instead of creating a separate instance of ScrollTrigger, since we do not need this timeline to be connected to a drag-and-drop element. (This code is entirely in forEach Loop.) We will add some twins to the timeline to animate the title and image.

/* 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 twins will play one after the other. But I use Location parameter To indicate that the heading tween should play 0.5 seconds from the beginning of the timeline, so that our animations overlap.

Here is the full demo in action:

See the pen
GSAP Draggable and ScrollTrigger timeline [FINAL]
By Michelle Barker (@michellebarker)
On CodePen.0

Looking Back on 2021: A Codrops Resource Summary

Source

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Popular Articles