Swiping horizontally between pages is a very common pattern in native apps, so it makes sense to bring this pattern to your mobile web app or PWA.
There are *a lot* of different ways to do this and I’ve tried many of them, but this has worked the best for me so far. I’m using this method in my hybrid app Imaginarie.
What’s in this tutorial
In this tutorial, we’ll start with a general overview of how this solution works. Then you’ll see a working JSFiddle with thoroughly commented code.
- What we’re building
- How it works
- The code
- Variations & options
What we’re building
We’re building a flexible component for swiping horizontally between pages or items. You can check out the demo, which is best tested on a mobile device.
- Native feel: Responsive to touch gestures, including quick swipes and longer pans
- Lazy: Render as few items in the DOM as possible, so the total number of items doesn’t affect performance
- Programmatic: With every swipe, we can update the route, send data to Analytics, etc.
- Vue, but could be easily adapted for React
- Hammer.JS touch library
- Lodash debounce function or equivalent
Alternative solutions: For a simple carousel, there are more lightweight solutions than this, including a horizontal scroll box using scroll-snap.
How it works
Part 1: Displaying items
For this example, we have 6 items in our list. But we don’t want to render all of them in the DOM at once. We only want 3 items: the previous, current, and next items. We’ll figure out the rendered items by keeping track of the index for the current item.
Starting state example:
items: [“red”, “orange”, “yellow”, “green”, “blue”, “purple”]
renderedItems: [“green”, “blue”, “purple”]
To go to the next or previous items, we’ll update
currentIndex which will update
renderedItems: [“blue”, “purple”, “red”]
renderedItems, we’ll create a flexbox container to display them horizontally, with the items centered. Inside, we’ll use
v-for to include
Important: Each item must have a unique and stable key so that Vue can track each item and avoid re-rendering items as we update the
Part 2: Adding movement
At this point, we could call our
previous functions and the item displayed would change on the screen instantly. But our goal is to have the items slide nicely across the screen.
To get a sliding effect, we’ll move the items by translating them left or right by the full width of the screen. To control how fast the items move, we’ll use a CSS transition.
Here’s the sequence of events for going to the next item:
- Translate items to the left for a duration of 250ms by applying these CSS properties to our item container:
transition: transform 250ms cubic-bezier(0.0, 0.0, 0.2, 1);
- We’ll listen for an event that tells us the transition is done. Then, we’ll change
currentIndexso that the next item becomes the current one and a new next item is rendered
Part 3: Handling Touch events
We’re already translating items across the screen, so now we can sync that up with touch gestures.
Here’s the idea: As a user is moving a finger across the screen, translate the items by the same amount. Then when they lift their finger, continue the movement across the screen.
Setting up Hammer.JS
This tutorial uses the popular open-source library Hammer to respond to touch events. It’s not totally without issues in my experience, but it handles a lot of the cross-browser complexity. This Google Developers tutorial on handling touch has great info if you want to avoid another library.
To get set up, we’ll create a new Hammer instance on a parent div and tell it to only look for horizontal swipe and pan gestures. Then we’ll add an event listener to respond to those gestures.
During a gesture, events are fired with a lot of info, but we’re only using three data points:
deltaX: The total distance traveled on the X-axis. Positive numbers mean left-to-right (previous), and negative numbers right-to-left (next)
deltaY: Distance on the Y-axis
isFinal: Whether the gesture is over (finger or mouse lifted)
Responding to events
Now we can react to quick
swipe and longer
- During a gesture, translate the items the same distance and direction as
- When the gesture is over, call the
nextfunctions based on the direction, which will translate the item the rest of the way off the screen
Keeping transitions smooth & responsive
For translating during a gesture, we want the transition to be instant and responsive. But when we finish the translation on gesture end, we want it to be smooth, with a duration and timing property applied. You’ll see in the code that we’ll be updating the transition class on the fly.
Part 4: Handling additional scenarios
At this point, our component would work OK, but it would be pretty buggy. So we have a few scenarios to solve for:
- Changing direction: What if a user starts a gesture going one direction, but then goes back the other way? In that scenario, we’ll assume the user wants to stay on the current item so we’ll reset it back to where it started.
- Vertical gestures: We only want to respond to gestures that are clearly horizontal, so we’ll ignore gestures that are more vertical. But if a gesture starts horizontally and then goes diagonal, we’ll continue to react to the horizontal distance.
- Too-frequent interactions: Things can get complicated if someone is swiping or tapping super quickly. The most straightforward way to deal with this is to not respond to taps or gestures while an item is still transitioning across the screen. Luckily this transition happens quickly enough (250ms) that it should still feel very responsive even if you’re swiping through quickly.
- Duplicate events: Sometimes gestures will fire duplicate events, so we don’t want to end up skipping multiple slides at once. To avoid this, we’re debouncing our
previousfunctions so that they are only called at most every 100ms.
Part 5: Adding tap targets (Optional)
For my app, I added another way to change slides: tapping the left or right edges of the screen (like on a Kindle). Implementing this is just a matter of adding two non-visible divs on the edges of the screen and adding click handlers to call the
Part 6: Adding edge effect for the end of the list (Optional)
What happens when a user tries to go to the next item when they’ve reached the end (or the previous item when they’re on the first)? Either the cards can go in an infinite loop, or we can stop the user from going forward or back when they reach one of the ends.
You can decide which option makes more sense for your use case, so in the example, the property
isInfiniteLoop controls this setting.
For the linear option, it’s a good practice to indicate that they’ve reached the end. There are multiple ways to do this, but I chose to replicate the common pattern of showing a curved overlay that comes into view as a user tries to swipe (not sure what it’s called).
To do this, we’ll add SVG curve shapes to each side of the screen with a starting scale of 0. We’ll keep track of whether we’re at one of these end cards and if we are we’ll intercept our gesture handler. Instead of translating the items like normal, we’ll increase the scale of the shape so it comes into view.
For people using a keyboard or assistive technology to navigate, gestures likely aren’t going to be very accessible. In this example, I made the left and right tap targets accessible to a keyboard and labeled them as buttons. The bold attributes below were added for accessibility:
<div class="touch-tap-right" role="button" aria-label="Next" tabindex="0" @click="next" @keyup.enter="next" @keyup.space="next">
This does introduce a slight problem for our design though. For items that are interactive, like buttons and input fields, browsers will automatically add an outline when they are active (i.e. a blue or gold blurred outline). This means that when we swipe or tap we’ll see an unexpected outline around the left or right sides of the screen.
So we don’t want the outline when we swipe, tap, or click, but we do want it for someone using a keyboard to navigate. This feature is only partially implemented across browsers, so in the meantime, I’m using a :focus-visible polyfill in my app. For this example, I just turned off the outline altogether, which isn’t recommended.
Also, for users who prefer fewer animations, the CSS includes the media query
prefers-reduced-motion to turn off all translation effects.
Variations & options
There are a lot of ways to customize this method and make it fit more use cases. Here a few ideas:
- Add progress dots: For a finite number of items, it can be nice to show users where they are in the set
- Add buttons for larger screens: In my app, I keep the cards the same width on larger screens but add arrow buttons for navigating
- Show more than one item at a time: For larger screens or smaller items, you may want to show multiple items at once. To do this, you would need to update the logic for displaying items and how far you’re translating the items when going to the next or previous items
Thanks for reading!
Please let me know if you have any questions, find a mistake, or have ideas on how to improve. And give some claps if you found this useful!