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.

Three stages showing the horizontal timeline moving from right to left while the page itself is scrolled vertically

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

The post Building a Scrollable and Draggable Timeline with GSAP appeared first on Codrops.

Creating an Infinite Circular Gallery using WebGL with OGL and GLSL Shaders

In this tutorial we’ll implement an infinite circular gallery using WebGL with OGL based on the website Lions Good News 2020 made by SHIFTBRAIN inc.

Most of the steps of this tutorial can be also reproduced in other WebGL libraries such as Three.js or Babylon.js with the correct adaptations.

With that being said, let’s start coding!

Creating our OGL 3D environment

The first step of any WebGL tutorial is making sure that you’re setting up all the rendering logic required to create a 3D environment.

Usually what’s required is: a camera, a scene and a renderer that is going to output everything into a canvas element. Then inside a requestAnimationFrame loop, you’ll use your camera to render a scene inside the renderer. So here’s our initial snippet:

import { Renderer, Camera, Transform } from 'ogl'
 
export default class App {
  constructor () {
    this.createRenderer()
    this.createCamera()
    this.createScene()
 
    this.onResize()
 
    this.update()
 
    this.addEventListeners()
  }
 
  createRenderer () {
    this.renderer = new Renderer()
 
    this.gl = this.renderer.gl
    this.gl.clearColor(0.79607843137, 0.79215686274, 0.74117647058, 1)
 
    document.body.appendChild(this.gl.canvas)
  }
 
  createCamera () {
    this.camera = new Camera(this.gl)
    this.camera.fov = 45
    this.camera.position.z = 20
  }
 
  createScene () {
    this.scene = new Transform()
  }
 
  /**
   * Events.
   */
  onTouchDown (event) {
      
  }
 
  onTouchMove (event) {
      
  }
 
  onTouchUp (event) {
      
  }
 
  onWheel (event) {
      
  }
 
  /**
   * Resize.
   */
  onResize () {
    this.screen = {
      height: window.innerHeight,
      width: window.innerWidth
    }
 
    this.renderer.setSize(this.screen.width, this.screen.height)
 
    this.camera.perspective({
      aspect: this.gl.canvas.width / this.gl.canvas.height
    })
 
    const fov = this.camera.fov * (Math.PI / 180)
    const height = 2 * Math.tan(fov / 2) * this.camera.position.z
    const width = height * this.camera.aspect
 
    this.viewport = {
      height,
      width
    }
  }
 
  /**
   * Update.
   */
  update () {
    this.renderer.render({
      scene: this.scene,
      camera: this.camera
    })
    
    window.requestAnimationFrame(this.update.bind(this))
  }
 
  /**
   * Listeners.
   */
  addEventListeners () {
    window.addEventListener('resize', this.onResize.bind(this))
 
    window.addEventListener('mousewheel', this.onWheel.bind(this))
    window.addEventListener('wheel', this.onWheel.bind(this))
 
    window.addEventListener('mousedown', this.onTouchDown.bind(this))
    window.addEventListener('mousemove', this.onTouchMove.bind(this))
    window.addEventListener('mouseup', this.onTouchUp.bind(this))
 
    window.addEventListener('touchstart', this.onTouchDown.bind(this))
    window.addEventListener('touchmove', this.onTouchMove.bind(this))
    window.addEventListener('touchend', this.onTouchUp.bind(this))
  }
}
 
new App()

Explaining the App class setup

In our createRenderer method, we’re initializing a renderer with a fixed color background by calling this.gl.clearColor. Then we’re storing our GL context (this.renderer.gl) reference in the this.gl variable and appending our <canvas> (this.gl.canvas) element to our document.body.

In our createCamera method, we’re creating a new Camera() instance and setting some of its attributes: fov and its z position. The FOV is the field of view of your camera, what you’re able to see from it. And the z is the position of your camera in the z axis.

In our createScene method, we’re using the Transform class, that is the representation of a new scene that is going to contain all our planes that represent our images in the WebGL environment.

The onResize method is the most important part of our initial setup. It’s responsible for three different things:

  1. Making sure we’re always resizing the <canvas> element with the correct viewport sizes.
  2. Updating our this.camera perspective dividing the width and height of the viewport.
  3. Storing in the variable this.viewport, the value representations that will help to transform pixels into 3D environment sizes by using the fov from the camera.

The approach of using the camera.fov to transform pixels in 3D environment sizes is an approach used very often in multiple WebGL implementations. Basically what it does is making sure that if we do something like: this.mesh.scale.x = this.viewport.width; it’s going to make our mesh fit the entire screen width, behaving like width: 100%, but in 3D space.

And finally in our update, we’re setting our requestAnimationFrame loop and making sure we keep rendering our scene.

You’ll also notice that we already included the wheel, touchstart, touchmove, touchend, mousedown, mousemove and mouseup events, they will be used to include user interactions with our application.

Creating a reusable geometry instance

It’s a good practice to keep memory usage low by always reusing the same geometry reference no matter what WebGL library you’re using. To represent all our images, we’re going to use a Plane geometry, so let’s create a new method and store this new geometry inside the this.planeGeometry variable.

import { Renderer, Camera, Transform, Plane } from 'ogl'
 
createGeometry () {
  this.planeGeometry = new Plane(this.gl, {
    heightSegments: 50,
    widthSegments: 100
  })
}

The reason for including heightSegments and widthSegments with these values is being able to manipulate vertices in a way to make the Plane behave like a paper in the air.

Importing our images using Webpack

Now it’s time to import our images into our application. Since we’re using Webpack in this tutorial, all we need to do to request our images is using import:

import Image1 from 'images/1.jpg'
import Image2 from 'images/2.jpg'
import Image3 from 'images/3.jpg'
import Image4 from 'images/4.jpg'
import Image5 from 'images/5.jpg'
import Image6 from 'images/6.jpg'
import Image7 from 'images/7.jpg'
import Image8 from 'images/8.jpg'
import Image9 from 'images/9.jpg'
import Image10 from 'images/10.jpg'
import Image11 from 'images/11.jpg'
import Image12 from 'images/12.jpg'

Now let’s create our array of images that we want to use in our infinite slider, so we’re basically going to call the variables above inside a createMedia method, and use .map to create new instances of the Media class (new Media()), which is going to be our representation of each image of the gallery.

createMedias () {
  this.mediasImages = [
    { image: Image1, text: 'New Synagogue' },
    { image: Image2, text: 'Paro Taktsang' },
    { image: Image3, text: 'Petra' },
    { image: Image4, text: 'Gooderham Building' },
    { image: Image5, text: 'Catherine Palace' },
    { image: Image6, text: 'Sheikh Zayed Mosque' },
    { image: Image7, text: 'Madonna Corona' },
    { image: Image8, text: 'Plaza de Espana' },
    { image: Image9, text: 'Saint Martin' },
    { image: Image10, text: 'Tugela Falls' },
    { image: Image11, text: 'Sintra-Cascais' },
    { image: Image12, text: 'The Prophet\'s Mosque' },
    { image: Image1, text: 'New Synagogue' },
    { image: Image2, text: 'Paro Taktsang' },
    { image: Image3, text: 'Petra' },
    { image: Image4, text: 'Gooderham Building' },
    { image: Image5, text: 'Catherine Palace' },
    { image: Image6, text: 'Sheikh Zayed Mosque' },
    { image: Image7, text: 'Madonna Corona' },
    { image: Image8, text: 'Plaza de Espana' },
    { image: Image9, text: 'Saint Martin' },
    { image: Image10, text: 'Tugela Falls' },
    { image: Image11, text: 'Sintra-Cascais' },
    { image: Image12, text: 'The Prophet\'s Mosque' },
  ]
 
 
  this.medias = this.mediasImages.map(({ image, text }, index) => {
    const media = new Media({
      geometry: this.planeGeometry,
      gl: this.gl,
      image,
      index,
      length: this.mediasImages.length,
      scene: this.scene,
      screen: this.screen,
      text,
      viewport: this.viewport
    })
 
    return media
  })
}

As you’ve probably noticed, we’re passing a bunch of arguments to our Media class, I’ll explain why they’re needed when we start setting up the class in the next section. We’re also duplicating the amount of images to avoid any issues of not having enough images when making our gallery infinite on very wide screens.

It’s important to also include some specific calls in the onResize and update methods for our this.medias array, because we want the images to be responsive:

onResize () {
  if (this.medias) {
    this.medias.forEach(media => media.onResize({
      screen: this.screen,
      viewport: this.viewport
    }))
  }
}

And also do some real-time manipulations inside the requestAnimationFrame:

update () {
  this.medias.forEach(media => media.update(this.scroll, this.direction))
}

Setting up the Media class

Our Media class is going to use Mesh, Program and Texture classes from OGL to create a 3D plane and attribute a texture to it, which in our case is going to be our images.

In our constructor, we’re going to store all variables that we need and that were passed in the new Media() initialization from index.js:

export default class {
  constructor ({ geometry, gl, image, index, length, renderer, scene, screen, text, viewport }) {
    this.geometry = geometry
    this.gl = gl
    this.image = image
    this.index = index
    this.length = length
    this.scene = scene
    this.screen = screen
    this.text = text
    this.viewport = viewport
 
    this.createShader()
    this.createMesh()
 
    this.onResize()
  }
}

Explaining a few of these arguments, basically the geometry is the geometry we’re going to apply to our Mesh class. The this.gl is our GL context, useful to keep doing WebGL manipulations inside the class. The this.image is the URL of the image. Both of the this.index and this.length will be used to do positions calculations of the mesh. The this.scene is the group which we’re going to append our mesh to. And finally this.screen and this.viewport are the sizes of the viewport and environment.

Now it’s time to create the shader that is going to be applied to our Mesh in the createShader method, in OGL shaders are created with Program:

createShader () {
  const texture = new Texture(this.gl, {
    generateMipmaps: false
  })
 
  this.program = new Program(this.gl, {
    fragment,
    vertex,
    uniforms: {
      tMap: { value: texture },
      uPlaneSizes: { value: [0, 0] },
      uImageSizes: { value: [0, 0] },
      uViewportSizes: { value: [this.viewport.width, this.viewport.height] }
      },
    transparent: true
  })
 
  const image = new Image()
 
  image.src = this.image
  image.onload = _ => {
    texture.image = image
 
    this.program.uniforms.uImageSizes.value = [image.naturalWidth, image.naturalHeight]
  }
}

In the snippet above, we’re basically creating a new Texture() instance, making sure to use generateMipmaps as false so it preserves the quality of the image. Then creating a new Program() instance, which represents a shader composed of fragment and vertex with some uniforms used to manipulate it.

We’re also creating a new Image() instance to preload the image before applying it to the texture.image. And also updating the this.program.uniforms.uImageSizes.value because it’s going to be used to preserve the aspect ratio of our images.

It’s important to create our fragment and vertex shaders now, so we’re going to create two new files: fragment.glsl and vertex.glsl:

precision highp float;
 
uniform vec2 uImageSizes;
uniform vec2 uPlaneSizes;
uniform sampler2D tMap;
 
varying vec2 vUv;
 
void main() {
  vec2 ratio = vec2(
    min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0),
    min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0)
  );
 
  vec2 uv = vec2(
    vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
    vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
  );
 
  gl_FragColor.rgb = texture2D(tMap, uv).rgb;
  gl_FragColor.a = 1.0;
}
precision highp float;
 
attribute vec3 position;
attribute vec2 uv;
 
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
 
varying vec2 vUv;
 
void main() {
  vUv = uv;
 
  vec3 p = position;
 
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}

And require them in the start of Media.js using Webpack:

import fragment from './fragment.glsl'
import vertex from './vertex.glsl'

Now let’s create our new Mesh() instance in the createMesh method merging together the geometry and shader.

createMesh () {
  this.plane = new Mesh(this.gl, {
    geometry: this.geometry,
    program: this.program
  })
 
  this.plane.setParent(this.scene)
}

The Mesh instance is stored in the this.plane variable to be reused in the onResize and update methods, then appended as a child of the this.scene group.

The only thing we have now on the screen is a simple square with our image:

Let’s now implement the onResize method and make sure we’re rendering rectangles:

onResize ({ screen, viewport } = {}) {
  if (screen) {
    this.screen = screen
  }
 
  if (viewport) {
    this.viewport = viewport
 
    this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height]
  }
 
  this.scale = this.screen.height / 1500
 
  this.plane.scale.y = this.viewport.height * (900 * this.scale) / this.screen.height
  this.plane.scale.x = this.viewport.width * (700 * this.scale) / this.screen.width
 
  this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y]
}

The scale.y and scale.x calls are responsible for scaling our element properly, transforming our previous square into a rectangle of 700×900 sizes based on the scale.

And the uViewportSizes and uPlaneSizes uniform value updates makes the image display correctly. That’s basically what makes the image have the background-size: cover; behavior, but in WebGL environment.

Now we need to position all the rectangles in the x axis, making sure we have a small gap between them. To achieve that, we’re going to use this.plane.scale.x, this.padding and this.index variables to do the calculation required to move them:

this.padding = 2
 
this.width = this.plane.scale.x + this.padding
this.widthTotal = this.width * this.length
 
this.x = this.width * this.index

And in the update method, we’re going to set the this.plane.position to these variables:

update () {
  this.plane.position.x = this.x
}

Now you’ve setup all the initial code of Media, which results in the following image:

Including infinite scrolling logic

Now it’s time to make it interesting and include scrolling logic on it, so we have at least an infinite gallery in place when the user scrolls through your page. In our index.js, we’ll do the following updates.

First, let’s include a new object called this.scroll in our constructor with all variables that we will manipulate to do the smooth scrolling:

this.scroll = {
  ease: 0.05,
  current: 0,
  target: 0,
  last: 0
}

Now let’s add the touch and wheel events, so when the user interacts with the canvas, he will be able to move stuff:

onTouchDown (event) {
  this.isDown = true
 
  this.scroll.position = this.scroll.current
  this.start = event.touches ? event.touches[0].clientX : event.clientX
}
 
onTouchMove (event) {
  if (!this.isDown) return
 
  const x = event.touches ? event.touches[0].clientX : event.clientX
  const distance = (this.start - x) * 0.01
 
  this.scroll.target = this.scroll.position + distance
}
 
onTouchUp (event) {
  this.isDown = false
}

Then, we’ll include the NormalizeWheel library in onWheel event, so this way we have the same value on all browsers when the user scrolls:

onWheel (event) {
  const normalized = NormalizeWheel(event)
  const speed = normalized.pixelY
 
  this.scroll.target += speed * 0.005
}

In our update method with requestAnimationFrame, we’ll lerp the this.scroll.current with this.scroll.target to make it smooth, then we’ll pass it to all medias:

update () {
  this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease)
 
  if (this.medias) {
    this.medias.forEach(media => media.update(this.scroll))
  }
 
  this.scroll.last = this.scroll.current
 
  window.requestAnimationFrame(this.update.bind(this))
}

And now we just update our Media file to use the current scroll value to move the Mesh to the new scroll position:

update (scroll) {
  this.plane.position.x = this.x - scroll.current * 0.1
}

This is the current result we have:

As you’ve noticed, it’s not infinite yet, to achieve that, we need to include some extra code. The first step is including the direction of the scroll in the update method from index.js:

update () {
  this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease)
 
  if (this.scroll.current > this.scroll.last) {
    this.direction = 'right'
  } else {
    this.direction = 'left'
  }
 
  if (this.medias) {
    this.medias.forEach(media => media.update(this.scroll, this.direction))
  }
 
  this.scroll.last = this.scroll.current
}

Now in the Media class, you need to include a variable called this.extra in the constructor, and do some manipulations on it to sum the total width of the gallery, when the element is outside of the screen.

constructor ({ geometry, gl, image, index, length, renderer, scene, screen, text, viewport }) {
  this.extra = 0
}

update (scroll) {
  this.plane.position.x = this.x - scroll.current * 0.1 - this.extra
    
  const planeOffset = this.plane.scale.x / 2
  const viewportOffset = this.viewport.width
 
  this.isBefore = this.plane.position.x + planeOffset < -viewportOffset
  this.isAfter = this.plane.position.x - planeOffset > viewportOffset
 
  if (direction === 'right' && this.isBefore) {
    this.extra -= this.widthTotal
 
    this.isBefore = false
    this.isAfter = false
  }
 
  if (direction === 'left' && this.isAfter) {
    this.extra += this.widthTotal
 
    this.isBefore = false
    this.isAfter = false
  }
}

That’s it, now we have the infinite scrolling gallery, pretty cool right?

Including circular rotation

Now it’s time to include the special flavor of the tutorial, which is making the infinite scrolling also have the circular rotation. To achieve it, we’ll use Math.cos to change the this.mesh.position.y accordingly to the rotation of the element. And map technique to change the this.mesh.rotation.z based on the element position in the z axis.

First, let’s make it rotate in a smooth way based on the position. The map method is basically a way to serve values based on another specific range, let’s say for example you use map(0.5, 0, 1, -500, 500);, it’s going to return 0 because it’s the middle between -500 and 500. Basically the first argument controls the output of min2 and max2:

export function map (num, min1, max1, min2, max2, round = false) {
  const num1 = (num - min1) / (max1 - min1)
  const num2 = (num1 * (max2 - min2)) + min2
 
  if (round) return Math.round(num2)
 
  return num2
}

Let’s see it in action by including the following like of code in the Media class:

this.plane.rotation.z = map(this.plane.position.x, -this.widthTotal, this.widthTotal, Math.PI, -Math.PI)

And that’s the result we get so far. It’s already pretty cool because you’re able to see the rotation changing based on the plane position:

Now it’s time to make it look circular. Let’s use Math.cos, we just need to do a simple calculation with this.plane.position.x / this.widthTotal, this way we’ll have a cos that will return a normalized value that we can just tweak multiplying by how much we want to change the y position of the element:

this.plane.position.y = Math.cos((this.plane.position.x / this.widthTotal) * Math.PI) * 75 - 75

Simple as that, we’re just moving it by 75 in environment space based in the position, this gives us the following result, which is exactly what we wanted to achieve:

Snapping to the closest item

Now let’s include a simple snapping to the closest item when the user stops scrolling. To achieve that, we need to create a new method called onCheck, it’s going to do some calculations when the user releases the scrolling:

onCheck () {
  const { width } = this.medias[0]
  const itemIndex = Math.round(Math.abs(this.scroll.target) / width)
  const item = width * itemIndex
 
  if (this.scroll.target < 0) {
    this.scroll.target = -item
  } else {
    this.scroll.target = item
  }
}

The result of the item variable is always the center of one of the elements in the gallery, which snaps the user to the corresponding position.

For wheel events, we need a debounced version of it called onCheckDebounce that we can include in the constructor by including lodash/debounce:

import debounce from 'lodash/debounce'
 
constructor ({ camera, color, gl, renderer, scene, screen, url, viewport }) {
  this.onCheckDebounce = debounce(this.onCheck, 200)
}
 
onWheel (event) {
  this.onCheckDebounce()
}

Now the gallery is always being snapped to the correct entry:

Writing paper shaders

Finally let’s include the most interesting part of our project, which is enhancing the shaders a little bit by taking into account the scroll velocity and distorting the vertices of our meshes.

The first step is to include two new uniforms in our this.program declaration from Media class: uSpeed and uTime.

this.program = new Program(this.gl, {
  fragment,
  vertex,
  uniforms: {
    tMap: { value: texture },
    uPlaneSizes: { value: [0, 0] },
    uImageSizes: { value: [0, 0] },
    uViewportSizes: { value: [this.viewport.width, this.viewport.height] },
    uSpeed: { value: 0 },
    uTime: { value: 0 }
  },
  transparent: true
})

Now let’s write some shader code to make our images bend and distort in a very cool way. In your vertex.glsl file, you should include the new uniforms: uniform float uTime and uniform float uSpeed:

uniform float uTime;
uniform float uSpeed;

Then inside the void main() of your shader, you can now manipulate the vertices in the z axis using these two values plus the position stored variable in p. We’re going to use a sin and cos to bend our vertices like it’s a plane, so all you need to do is including the following line:

p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5);

Also don’t forget to include uTime increment in the update() method from Media:

this.program.uniforms.uTime.value += 0.04

Just this line of code outputs a pretty cool paper effect animation:

Including text in WebGL using MSDF fonts

Now let’s include our text inside the WebGL, to achieve that, we’re going to use msdf-bmfont to generate our files, you can see how to do that in this GitHub repository, but basically it’s installing the npm dependency and running the command below:

msdf-bmfont -f json -m 1024,1024 -d 4 --pot --smart-size freight.otf

After running it, you should now have a .png and .json file in the same directory, these are the files that we’re going to use on our MSDF implementation in OGL.

Now let’s create a new file called Title and start setting up the code of it. First let’s create our class and use import in the shaders and the files:

import AutoBind from 'auto-bind'
import { Color, Geometry, Mesh, Program, Text, Texture } from 'ogl'
 
import fragment from 'shaders/text-fragment.glsl'
import vertex from 'shaders/text-vertex.glsl'
 
import font from 'fonts/freight.json'
import src from 'fonts/freight.png'
 
export default class {
  constructor ({ gl, plane, renderer, text }) {
    AutoBind(this)
 
    this.gl = gl
    this.plane = plane
    this.renderer = renderer
    this.text = text
 
    this.createShader()
    this.createMesh()
  }
}

Now it’s time to start setting up MSDF implementation code inside the createShader() method. The first thing we’re going to do is create a new Texture() instance and load the fonts/freight.png one stored in src:

createShader () {
  const texture = new Texture(this.gl, { generateMipmaps: false })
  const textureImage = new Image()
 
  textureImage.src = src
  textureImage.onload = _ => texture.image = textureImage
}

Then we need to start setting up the fragment shader we’re going to use to render the MSDF text, because MSDF can be optimized in WebGL 2.0, we’re going to use this.renderer.isWebgl2 from OGL to check if it’s supported or not and declare different shaders based on it, so we’ll have vertex300, fragment300, vertex100 and fragment100:

createShader () {
  const vertex100 = `${vertex}`
 
  const fragment100 = `
    #extension GL_OES_standard_derivatives : enable
 
    precision highp float;
 
    ${fragment}
  `
 
  const vertex300 = `#version 300 es
 
    #define attribute in
    #define varying out
 
    ${vertex}
  `
 
  const fragment300 = `#version 300 es
 
    precision highp float;
 
    #define varying in
    #define texture2D texture
    #define gl_FragColor FragColor
 
    out vec4 FragColor;
 
    ${fragment}
  `
 
  let fragmentShader = fragment100
  let vertexShader = vertex100
 
  if (this.renderer.isWebgl2) {
    fragmentShader = fragment300
    vertexShader = vertex300
  }
 
  this.program = new Program(this.gl, {
    cullFace: null,
    depthTest: false,
    depthWrite: false,
    transparent: true,
    fragment: fragmentShader,
    vertex: vertexShader,
    uniforms: {
      uColor: { value: new Color('#545050') },
      tMap: { value: texture }
    }
  })
}

As you’ve probably noticed, we’re prepending fragment and vertex with different setup based on the renderer WebGL version, let’s create also our text-fragment.glsl and text-vertex.glsl files:

uniform vec3 uColor;
uniform sampler2D tMap;
 
varying vec2 vUv;
 
void main() {
  vec3 color = texture2D(tMap, vUv).rgb;
 
  float signed = max(min(color.r, color.g), min(max(color.r, color.g), color.b)) - 0.5;
  float d = fwidth(signed);
  float alpha = smoothstep(-d, d, signed);
 
  if (alpha < 0.02) discard;
 
  gl_FragColor = vec4(uColor, alpha);
}
attribute vec2 uv;
attribute vec3 position;
 
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
 
varying vec2 vUv;
 
void main() {
  vUv = uv;
 
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Finally let’s create the geometry of our MSDF font implementation in the createMesh() method, for that we’ll use the new Text() instance from OGL, and then apply the buffers generated from it to the new Geometry() instance:

createMesh () {
  const text = new Text({
    align: 'center',
    font,
    letterSpacing: -0.05,
    size: 0.08,
    text: this.text,
    wordSpacing: 0,
  })
 
  const geometry = new Geometry(this.gl, {
    position: { size: 3, data: text.buffers.position },
    uv: { size: 2, data: text.buffers.uv },
    id: { size: 1, data: text.buffers.id },
    index: { data: text.buffers.index }
  })
 
  geometry.computeBoundingBox()
 
  this.mesh = new Mesh(this.gl, { geometry, program: this.program })
  this.mesh.position.y = -this.plane.scale.y * 0.5 - 0.085
  this.mesh.setParent(this.plane)
}

Now let’s apply our brand new titles in the Media class, we’re going to create a new method called createTilte() and apply it to the constructor:

constructor ({ geometry, gl, image, index, length, renderer, scene, screen, text, viewport }) {
  this.createTitle()
}

createTitle () {
  this.title = new Title({
    gl: this.gl,
    plane: this.plane,
    renderer: this.renderer,
    text: this.text,
  })
}

Simple as that, we’re just including a new Title() instance inside our Media class, this will output the following result for you:

One of the best things about rendering text inside WebGL is reducing the overload of calculations required by the browser when animating the text to the right position. If you go with the DOM approach, you’ll usually have a little bit of performance impact because browsers will need to recalculate DOM sections when translating the text properly and checking composite layers.

For the purpose of this demo, we also included a new Number() class implementation that will be responsible for showing the current index that the user is seeing. You can check how it’s implemented in source code, but it’s basically the same implementation of the Title class with the only difference of it loading a different font style:

Including background blocks

To finalize the demo, let’s implement some blocks in the background that will be moving in x and y axis to enhance the depth effect of it:

To achieve this effect we’re going to create a new Background class and inside of it we’ll initialize some new Plane() geometries in a new Mesh() with random sizes and positions by changing the scale and position of the meshes of the for loop:

import { Color, Mesh, Plane, Program } from 'ogl'
 
import fragment from 'shaders/background-fragment.glsl'
import vertex from 'shaders/background-vertex.glsl'
 
import { random } from 'utils/math'
 
export default class {
  constructor ({ gl, scene, viewport }) {
    this.gl = gl
    this.scene = scene
    this.viewport = viewport
 
    const geometry = new Plane(this.gl)
    const program = new Program(this.gl, {
      vertex,
      fragment,
      uniforms: {
        uColor: { value: new Color('#c4c3b6') }
      },
      transparent: true
    })
 
    this.meshes = []
 
    for (let i = 0; i < 50; i++) {
      let mesh = new Mesh(this.gl, {
        geometry,
        program,
      })
 
      const scale = random(0.75, 1)
 
      mesh.scale.x = 1.6 * scale
      mesh.scale.y = 0.9 * scale
 
      mesh.speed = random(0.75, 1)
 
      mesh.xExtra = 0
 
      mesh.x = mesh.position.x = random(-this.viewport.width * 0.5, this.viewport.width * 0.5)
      mesh.y = mesh.position.y = random(-this.viewport.height * 0.5, this.viewport.height * 0.5)
 
      this.meshes.push(mesh)
 
      this.scene.addChild(mesh)
    }
  }
}

Then after that we just need to apply endless scrolling logic on them as well, following the same directional validation we have in the Media class:

update (scroll, direction) {
  this.meshes.forEach(mesh => {
    mesh.position.x = mesh.x - scroll.current * mesh.speed - mesh.xExtra
 
    const viewportOffset = this.viewport.width * 0.5
    const widthTotal = this.viewport.width + mesh.scale.x
 
    mesh.isBefore = mesh.position.x < -viewportOffset
    mesh.isAfter = mesh.position.x > viewportOffset
 
    if (direction === 'right' && mesh.isBefore) {
      mesh.xExtra -= widthTotal
 
      mesh.isBefore = false
      mesh.isAfter = false
    }
 
    if (direction === 'left' && mesh.isAfter) {
      mesh.xExtra += widthTotal
 
      mesh.isBefore = false
      mesh.isAfter = false
    }
 
    mesh.position.y += 0.05 * mesh.speed
 
    if (mesh.position.y > this.viewport.height * 0.5 + mesh.scale.y) {
      mesh.position.y -= this.viewport.height + mesh.scale.y
    }
  })
}

That’s simple as that, now we have the blocks in the background as well, finalizing the code of our demo!

I hope this tutorial was useful to you and don’t forget to comment if you have any questions!

The post Creating an Infinite Circular Gallery using WebGL with OGL and GLSL Shaders appeared first on Codrops.

Crafting a Scrollable and Draggable Parallax Slider

In this article, I’ll show you how to build a parallax slider with a fun reveal animation. I’ll be using GSAP, CSS Grid and Flexbox and I’ll assume that you have some basic knowledge on how to use these techniques. Besides that, I’ll be using a custom smooth scroll based on Virtual Scroll for a better experience.

The article is split up in 3 steps:

  1. The hover animation
  2. The open/expand animation
  3. The slider scroll/drag parallax effect

 For a better understanding I added motion videos for each step.

1. Hover animation

When we hover the button on the top right, 3 placeholder images will animate in the viewport from the right side.

Markup

<button class="button-slider-open js-slider-open" type="button">
  <svg>...</svg>
</button>

<div class="placeholders js-placeholders">
  <div class="placeholders__img-wrap js-img-wrap" style="--aspect-ratio: 0.8;">
    <img
      src="https://images.unsplash.com/photo-1479839672679-a46483c0e7c8?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80&ar=0.8"
      class="placeholders__img"
    >
  </div>

  ...
</div>

Animation

We have a this.dom object where we store all the DOM elements such as the images. When initializing the app we call the setHoverAnimation which creates a GSAP timeline that is set default to paused. In the timeline we will animate the 3 images into the viewport so they’re partly visible. The user will then have the impression that they can be expanded on click.

this.dom = {};
this.dom.el = document.querySelector('.js-placeholders');
this.dom.images = this.dom.el.querySelectorAll('.js-img-wrap');
this.dom.buttonOpen = document.querySelector('.js-slider-open');
setHoverAnimation() {
  this.tl = gsap.timeline({ paused: true });

  this.tl
    .addLabel('start')

    .set(this.dom.el, { autoAlpha: 1 })
    .set(this.dom.images, { scale: 0.5, x: (window.innerWidth / 12) * 1.2, rotation: 0 })

    .to(this.dom.images, { duration: 1, stagger: 0.07, ease: 'power3.inOut', x: 0, y: 0 })
    .to(this.dom.images[0], { duration: 1, ease: 'power3.inOut', rotation: -4 }, 'start')
    .to(this.dom.images[1], { duration: 1, ease: 'power3.inOut', rotation: -2 }, 'start');
}

To trigger the animation when hovering the button, we created 2 events (handleMouseenter and handleMouseleave) which will play the GSAP timeline or simply reverse the animation.

this.dom.buttonOpen.addEventListener('mouseenter', this.handleMouseenter);
this.dom.buttonOpen.addEventListener('mouseleave', this.handleMouseleave);

handleMouseenter() {
  this.tl.play();
}

handleMouseleave() {
  this.tl.reverse();
}

2. Expand placeholder items

When we click the button on the top right, after hovering, the 3 placeholder images will animate to a 3 column grid. There will be more than 3 items in the slider, but only the first 3 will be visible in the viewport so it’s not necessary to animate the other items. Once the 3 placeholder items are in the correct position we can display the actual slider items underneath and remove the placeholder items.

Animation

First we have to calculate the position that the placeholder items will have to animate to. Subtracting the left position of the placeholder items with the left position of the slider items will get you the correct x position. To get the y position I’m doing the exact same thing, but use the top bounds instead.

const x1 = this.bounds.left - slider.items[0].bounds.left - 20;
const x2 = this.bounds.left - slider.items[1].bounds.left + 10;
const x3 = this.bounds.left - slider.items[2].bounds.left;

const y1 = this.bounds.top - slider.items[0].bounds.top + 10;
const y2 = this.bounds.top - slider.items[1].bounds.top - 30;
const y3 = this.bounds.top - slider.items[2].bounds.top + 30;

The placeholder items are smaller than the slider items so we have to scale them up. To calculate the scale value we will just divide the width of the placeholder items by the width of one of the slider items (they are all the same size).

const scale = slider.items[0].bounds.width / this.bounds.width;

The intersectX1 X2 X3 values are used to set the initial x position of the images inside its container. Each image in the slider will have its own x position stored. The x position will be used to animate the slider images inside its container, this will create the parallax effect.

const intersectX1 = slider.items[0].x;
const intersectX2 = slider.items[1].x;
const intersectX3 = slider.items[2].x;

A new GSAP timeline will be created for the expand animation. Once the timeline is completed setHoverAnimation will be fired so the placeholder items are reset and ready to animate again. Also we will start the reveal animation of the slider elements such as the text on the items and the close button, but we will not go into the text animations. You can find that in the final code.

this.tl = gsap.timeline({
  onComplete: () => {
    this.setHoverAnimation();
    slider.open();
  }
});

The 3 placeholder images will animate to the x and y position that we defined earlier. Let’s have a look at the GSAP timeline.

this.tl
  .addLabel('start')
  .to(this.dom.images[0], { duration: 1.67, ease: 'power3.inOut', x: -x1, y: -y1, scale, rotation: 0 }, 'start')
  .to(this.dom.images[1], { duration: 1.67, ease: 'power3.inOut', x: -x2, y: -y2, scale, rotation: 0 }, 'start')
  .to(this.dom.images[2], { duration: 1.67, ease: 'power3.inOut', x: -x3, y: -y3, scale, rotation: 0 }, 'start')
  .to(this.dom.images[0].querySelector('img'), { duration: 1.67, ease: 'power3.inOut', x: intersectX1 }, 'start')
  .to(this.dom.images[1].querySelector('img'), { duration: 1.67, ease: 'power3.inOut', x: intersectX2 }, 'start')
  .to(this.dom.images[2].querySelector('img'), { duration: 1.67, ease: 'power3.inOut', x: intersectX3 }, 'start',)
  .set(this.dom.el, { autoAlpha: 0 }, 'start+=1.67')

3. Parallax effect

The images are scaled up in its container that will have `overflow: hidden`. Once you move the slider, the images that are in the viewport will move with a slightly different speed.

Markup

<div class="slider js-slider">
  <div class="slider__container js-container" data-scroll>
    <div class="slider__item js-item" style="--aspect-ratio: 0.8;">
      <div class="slider__item-img-wrap js-img-wrap js-img" style="--aspect-ratio: 0.8;">
        <img
          src="https://images.unsplash.com/photo-1472835560847-37d024ebacdc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80&ar=0.8"
          class="slider__item-img"
        >
      </div>

      <div class="slider__item-content">
        <div class="slider__item-heading-wrap">
          <h3 class="slider__item-heading">
            Indigo
          </h3>
        </div>

        <div class="slider__item-button-wrap">
          <button class="button slider__item-button" type="button">
            Read more
          </button>
        </div>
      </div>
    </div>

    ...
  </div>
</div>
<div class="slider__progress-wrap js-progress-wrap">
  <div class="slider__progress js-progress"></div>
</div>

Animation

We will be using a smooth scroll on top of Virtual Scroll, but we will not go into how to set this up (please check the documentation). With interpolation, we can achieve this smooth parallax scrolling effect.

Once again we have a this.dom object where we will store all the DOM elements that we need.

this.dom = {};
this.dom.el = document.querySelector('.js-slider');
this.dom.container = this.dom.el.querySelector('.js-container');
this.dom.items = this.dom.el.querySelectorAll('.js-item');
this.dom.images = this.dom.el.querySelectorAll('.js-img-wrap');
this.dom.progress = this.dom.el.querySelector('.js-progress');

To keep track of the scroll progress we created a simple progress bar which defines how much we moved the slider. We simply calculate a value between 0 and 1 that represents the x position of the slider container. This value will be updated in a requestAnimationFrame and used to animate the scaleX value of the progress bar.

const max = -this.dom.container.offsetWidth + window.innerWidth;
const progress = ((scroll.state.last - 0) * 100) / (max - 0) / 100;

this.dom.progress.style.transform = `scaleX(${progress})`;

Before we start the animation of the parallax effect on the images we first store some data of each slider item in the array this.items. This array wil contain the image element, the bounds and the x position of the image.

setCache() {
  this.items = [];
  [...this.dom.items].forEach((el) => {
    const bounds = el.getBoundingClientRect();

    this.items.push({
      img: el.querySelector('img'),
      bounds,
      x: 0,
    });
  });
}

Now we’re finally ready to create the parallax effect on the images. The code below will be executed in a render function in a requestAnimationFrame. In this function we will be using the interpolated value of our scroll (scroll.state.last).

For a better performance we will only animate the images that are in the viewport. To do so we will detect which items are visible in the viewport.

const { bounds } = item;
const inView = scrollLast + window.innerWidth >= bounds.left && scrollLast < bounds.right;

If the item is visible in the viewport, we will calculate a value between 0 and 100 (percentage) that indicates how much of the target element is actually visible within the viewport.

const min = bounds.left - window.innerWidth;
const max = bounds.right;
const percentage = ((scrollLast - min) * 100) / (max - min);

Once we have that value stored, we can calculate a new value based on percentage that we will then transform into a pixel value like this.

const newMin = -(window.innerWidth / 12) * 3;
const newMax = 0;
item.x = ((percentage - 0) / (100 - 0)) * (newMax - newMin) + newMin;

After calculating that final x value we can now simply animate the image inside its container like this.

item.img.style.transform = `translate3d(${item.x}px, 0, 0)`;

This is what the render function looks like.

render() {
  const scrollLast = scroll.state.last;

  this.items.forEach((item) => {
    const { bounds } = item;
    const inView = scrollLast + window.innerWidth >= bounds.left && scrollLast < bounds.right;

    if (inView) {
      const min = bounds.left - window.innerWidth;
      const max = bounds.right;
      const percentage = ((scrollLast - min) * 100) / (max - min);
      const newMin = -(window.innerWidth / 12) * 3;
      const newMax = 0;
      item.x = ((percentage - 0) / (100 - 0)) * (newMax - newMin) + newMin;

      item.img.style.transform = `translate3d(${item.x}px, 0, 0) scale(1.75)`;
    }
  });
}

I hope this has been not too difficult to follow and that you have gained some insight into creating this parallax slider.

If you would like to see this slider live in action, let’s have a look at the architectural website for Nieuw Bergen by Gewest13. It’s used to showcase their seven buildings.

Please let me know if you have any questions @rluijtenant.

The post Crafting a Scrollable and Draggable Parallax Slider appeared first on Codrops.

Image Dragging Effects

Today we’d like to share a little set of playful dragging effects with you. The idea is to animate images as they are being dragged, and distort, scale or apply filters to them. For some examples we tie the intensity of the effect to the speed of the dragging motion.

The inspiration for these effects come from two Dribbble shots by Zhenya Rynzhuk:

Here’s a short video of all effects:

We hope you like these ideas and find them useful!

References and Credits

Image Dragging Effects was written by Mary Lou and published on Codrops.

Draggable Menu with Image Grid Previews

After our little draggable image strip experiment, we wanted to explore using the dragging functionality on a menu. The idea is to show a large inline menu with some scattered thumbnails. The menu can be dragged and while doing so, the thumbnails get moved with an animation. Each menu item also changes the letters to show an outlined version. When clicking on the “explore” link under a menu item, the thumbnails move and enlarge to form a grid.

The animations are powered by TweenMax and we use Dave DeSandro’s Draggabilly.

Attention: Note that the demo is experimental and that we use modern CSS properties that might not be supported in older browsers.

The initial view looks as follows:

DraggableMenu_01

When clicking on the explore link, we animate all thumbnails to their place in a custom grid.

DraggableMenu_02

You can see it in action here:

DraggableMenu.2019-06-19 10_45_16_optimized

We hope you enjoy this menu and find it useful!

References and Credits

Draggable Menu with Image Grid Previews was written by Mary Lou and published on Codrops.

Draggable Image Strip

Today we’d like to share a little draggable experiment with you. The idea is to show a strip of differently sized images that can be dragged. When clicking and holding to drag, a title element appears and the images get scaled. This separates the images and gives the whole thing an interesting look. When a number gets clicked, the same separation happens and then the images fly up. Another larger version of the images slides in from the bottom.

The animations are powered by TweenMax and we use Dave DeSandro’s Draggabilly.

Attention: Note that the demo is experimental and that we use modern CSS properties that might not be supported in older browsers.

The initial view looks as follows:

DraggableImageStrip_01

When we click to drag the image strip, we show the title:

DraggableImageStrip_02

When clicking on a number, we slide the images up and show a larger view with some content:

DraggableImageStrip_03

Here’s a preview of the whole thing in motion:

DraggableImageStrip.2019-06-11 11_54_43

We hope you enjoy this experiment and find it useful!

References and Credits

Draggable Image Strip was written by Mary Lou and published on Codrops.